The Shackle Almanac
This booklet contains the (early) design and documentation of the Shackle constraint modelling framework. The Shackle project was started as a redesign of the MiniZinc compiler, and the handling the MiniZinc language remains its primary goal. Early research into a design of such a framework was conducted in Jip J. Dekker's thesis: "A Modern Architecture for Constraint Modelling Languages" The intention of this document is to be updated throughout the design process as to both keep a history of the design of this framework and make this a start of the internal documentation of the framework.
Acknowledgements
This research was partially funded by the Australian Government through the Australian Research Council Industrial Transformation Training Centre in Optimisation Technologies, Integrated Methodologies, and Applications (OPTIMA), Project ID IC200100009.
The compilation process
The Shackle compiler takes a MiniZinc model and compiles it into a bytecode program which is interpreted to produce FlatZinc for solvers. Compilation is divided into modules dealing with each of the major source code representations. Frontend analysis predominantly uses the high-level intermediate representation (HIR). The typed high-level (THIR) and mid-level (MIR) intermediate representations deal with transforming the MiniZinc program into MicroZinc, then finally the bytecode generation step takes place to produce the final compiled program. This program is data-independent. The interpreter parses the data runs the bytecode program to generates NanoZinc and ultimately FlatZinc for the solver.
flowchart TD; mzn>MiniZinc model files]--Parsing-->cst["Parse tree (CST)"] cst-->ast["Abstract syntax tree (AST)"] ast--"Lowering (syntactic desugaring)"-->hir["High-level intermediate representation"] subgraph parser [Parsing] cst ast end hir-->validation[/"Validation (e.g. type checking)"/] validation-->thir["Typed high-level intermediate representation"] validation-->ls["Code tools (e.g. language server)"] thir-->transform[/"Basic transformations"/] transform-->mir["Middle-level intermediate representation"] mir-->analysis[/"Code analysis (e.g. mode analysis)"/] analysis-->rewrite[/"Rewriting"/] rewrite-->uzn[MicroZinc] uzn-->codegen[/"Code generation"/] codegen-->bc[Bytecode] subgraph compiler [Data independent compilation] hir thir mir validation transform analysis rewrite uzn codegen bc end dzn>Data files] bc--->interpret dzn-->interpret interpret[/Interpretation/] interpret-->nzn[NanoZinc] subgraph interpreter [Interpreter] interpret nzn end nzn-->solver[FlatZinc solver] solver-->result[Result] result-->heuristic[Search heuristic] heuristic-->solution(Solution) heuristic--->interpret subgraph solving [Solving] solver result heuristic solution end
Compilation stages
- Parsing
tree-sitter
is used to generate a CST from a MiniZinc model. - Abstract syntax tree
Type-safe accessors for the CST, used for include resolution. - High-level intermediate representation
A syntactic desugaring phase for the first main intermediate representation.
Includes scope collection, name resolution, function resolution, type checking, and various validation checks. - Typed high-level intermediate representation
A semantic desugaring phase which combines the HIR nodes with their computed types.
Includes type specialisation and removal of optional and enum types, as well as other model-level transformations. - Mid-level intermediate representation
The MiniZinc THIR is transformed into a MicroZinc AST. - Bytecode generation
Code generation of a program which will be interpreted to generate the final FlatZinc. - Bytecode interpretation
The bytecode along with the data is interpreted to produce NanoZinc and later FlatZinc or any other format for solver backends.
Query-based architecture
The compiler utilises Salsa
to provide a demand-driven, incremental architecture.
Salsa tracks the dependencies for queries, and memoises query results to avoid recomputation where possible. This is
especially useful for the language server. The compiler frontend up to the HIR stage is designed to be incremental
with respect to changing input files - if a model is changed, then only the changed portion, and anything that depended
on it will be recomputed.
This means we use a 'pull' architecture - tasks are done lazily when demanded. The main query produces the compiled
program, requires the MIR, which in turn requires the THIR, which in turn requires the HIR, which in turn requires
the parsed model files. The CompilerDatabase
is the main query database for the compiler. The actual queries are
defined in the database trait for the module they belong to.
Development
- THIR can be pretty-printed to produce a model file which can be fed to the old compiler
- MIR should be able to do the same
- This allows us to test the compiler without having to also write the interpreter
Other compilers
Much of the design of the compiler has been influenced by other projects, particularly
Parsing
Tree-sitter is used to generate a concrete syntax tree from a MiniZinc model. The grammar is located in the
parsers/tree-sitter-minizinc/grammar.js
file. There is also a corpus of tests which can be run to test the parser's
output.
Since this concrete syntax tree is too low level to perform most useful compilation steps, an abstract syntax tree will be constructed (with the AST nodes linked to the related CST nodes) during AST generation.
Example
The model
test foo() = true;
function bool: bar() = true;
var 1..3: x;
Gives the CST
kind="source_file", named=true, has_error=false, error=false, missing=false, extra=false, field=None
kind="predicate", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("item")
kind="test", named=false, has_error=false, error=false, missing=false, extra=false, field=Some("type")
kind="identifier", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("name")
kind="(", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind=")", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="=", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="boolean_literal", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("body")
kind="true", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind=";", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="function_item", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("item")
kind="function", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="type_base", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("type")
kind="primitive_type", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("domain")
kind="bool", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind=":", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="identifier", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("name")
kind="(", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind=")", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="=", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="boolean_literal", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("body")
kind="true", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind=";", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="declaration", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("item")
kind="type_base", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("type")
kind="var", named=false, has_error=false, error=false, missing=false, extra=false, field=Some("var_par")
kind="infix_operator", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("domain")
kind="integer_literal", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("left")
kind="..", named=false, has_error=false, error=false, missing=false, extra=false, field=Some("operator")
kind="integer_literal", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("right")
kind=":", named=false, has_error=false, error=false, missing=false, extra=false, field=None
kind="identifier", named=true, has_error=false, error=false, missing=false, extra=false, field=Some("name")
kind=";", named=false, has_error=false, error=false, missing=false, extra=false, field=None
AST generation
After parsing, a MiniZinc AST is generated from the CST. This provides type-safe accessors to the nodes in the syntax tree. No desugaring takes place at this stage, and all semantic nodes are are made available other than parentheses (which made explicit in the tree structure). So nodes like whitespace, comments and semicolons from the CST are removed in the AST.
This is still too low level for most analysis, and there several constructs which are semantically the same, but with
different syntactic representations. Therefore, the next step is to resolve the include
items, and then
lower each model into HIR.
Example
The model
test foo() = true;
function bool: bar() = true;
var 1..3: x;
Gives the AST
MznModel(
Model {
items: [
Predicate(
Predicate {
cst_kind: "predicate",
declared_type: Test,
id: UnquotedIdentifier(
UnquotedIdentifier {
cst_kind: "identifier",
name: "foo",
},
),
parameters: [],
body: Some(
BooleanLiteral(
BooleanLiteral {
cst_kind: "boolean_literal",
value: true,
},
),
),
annotations: [],
},
),
Function(
Function {
cst_kind: "function_item",
return_type: TypeBase(
TypeBase {
cst_kind: "type_base",
var_type: None,
opt_type: None,
any_type: false,
domain: Unbounded(
UnboundedDomain {
cst_kind: "primitive_type",
primitive_type: Bool,
},
),
},
),
id: UnquotedIdentifier(
UnquotedIdentifier {
cst_kind: "identifier",
name: "bar",
},
),
parameters: [],
body: Some(
BooleanLiteral(
BooleanLiteral {
cst_kind: "boolean_literal",
value: true,
},
),
),
annotations: [],
},
),
Declaration(
Declaration {
cst_kind: "declaration",
pattern: Identifier(
UnquotedIdentifier(
UnquotedIdentifier {
cst_kind: "identifier",
name: "x",
},
),
),
declared_type: TypeBase(
TypeBase {
cst_kind: "type_base",
var_type: Some(
Var,
),
opt_type: None,
any_type: false,
domain: Bounded(
InfixOperator(
InfixOperator {
cst_kind: "infix_operator",
left: IntegerLiteral(
IntegerLiteral {
cst_kind: "integer_literal",
value: Ok(
1,
),
},
),
operator: Operator {
cst_kind: "..",
name: "..",
},
right: IntegerLiteral(
IntegerLiteral {
cst_kind: "integer_literal",
value: Ok(
3,
),
},
),
},
),
),
},
),
definition: None,
annotations: [],
},
),
],
},
)
Include resolution
Include resolution involves recursively going through the include
items in a MiniZinc model, and finding the linked
model files. There are also two 'implied' includes which make the standard library available. These are stdlib.mzn
and solver_redefinitions.mzn
. While this process is described here as part of the AST module, as the include items
are extracted from the AST, it actually produces ModelRef
IDs, which are part of the HIR module.
Include resolution gives a list of ModelRef
s which comprises all of the model files to be lowered into
HIR. Logically these model files get concatenated together, however we don't actually do this until
the THIR stage. The list of models produced is in the order that the models appear.
Resolution rules
The resolution method used depends on the kind of path used in the include item. Note that relative includes are never
resolved relative to the process's current directory, only to the current model file (so string models without files
cannot use ./path/to/model.mzn
includes).
Include | Resolution method |
---|---|
|
|
|
|
|
|
Cyclic includes
As all models are locally concatenated together, it actually does not matter if there are includes which are cyclic. We only ever add any particular model file to the list of models once, so cycles are simply ignored.
Error handling
Errors during include resolution prevent lowering to HIR from occurring (as without having all of the included files available, it is highly likely that uninformative name resolution and type errors will occur). Note that when we fail to resolve an include, we actually don't abort include resolution. This way, we can produce errors for as many missing files as possible.
High-level intermediate representation (HIR)
The HIR is the representation used for validating the input MiniZinc program. Name resolution and type checking results are used by the language server to provide type information and diagnostics. This phase of the compiler is incremental with respect to changing model code. When an item hasn't changed (e.g. white-space changed, or the change happened in a different file), then results of relevant analyses can be reused.
Structure and IDs
HIR nodes are assigned 'IDs' which are generally based on numeric indices which can be used to access the node itself.
The HIR is structured such that each .mzn
file gets its own Model
, each of which which gets a ModelRef
for its ID.
Models contain arenas to store each kind of top-level item. Local to each model, items can be referred to using their
arena index, and the LocalItemRef
type can be used to store the index of any item. The ItemRef
type includes both
the ModelRef
and the LocalItemRef
for an item, meaning it can be used to globally identify any item.
Expressions, user-ascribed types, and patterns (called HIR 'entities') are stored in arenas inside each item, and can be
locally referred to using their arena indices. To refer to one of these entities globally, the ExpressionRef
,
TypeRef
and PatternRef
IDs can be used. EntityRef
can be used to store a reference to any of these entities.
It should be noted that local items inside let expressions use their parent's expression storage and do not themselves
store their expressions internally like top-level items.
There is also a NodeRef
type for referencing an arbitrary HIR node (i.e. models, items, entities).
This is mainly useful for tracking origins of nodes.
Types in the HIR
The Type
s used in the HIR represent user-written types, and may be incomplete. This is in contrast
to the Ty
type, which is type computed by the type-checker, and is always complete.
Consider:
any: x = 1;
In this case, the Type
for the declaration of x
will be any
(set during lowering), but the Ty
will be par int
(computed during type checking).
Lowering from the AST
Lowering from AST is done on a per-model basis, accessed using the db.lookup_model(model_ref)
query.
The actual HIR trees are kept separate from the source-mapping back to AST, which allows analysis queries to ignore the
source-mapping (unless they are emitting diagnostics) and avoid unnecessary recomputation when source locations change.
Lowering AST expressions is done by walking the AST and allocating the HIR expressions bottom up. We also apply some syntactic desugarings during this process. Patterns and types are also lowered in a similar way.
As the HIR nodes are built up, a SourceMap
containing the original AST nodes and the type of desugaring which occurred
(if any) is populated. This source mapping can be accessed with db.lookup_source_map(model_ref)
. This should only
be done if a diagnostic needs to be produced.
One complication in lowering is that we use different representations for enum assignment items and parameter assignment items, even though we cannot distinguish them syntactically. To deal with this, we keep track of the names of the enum items in the AST, so that we can detect what kind of assignment item is being used.
A further complication is that type-inst identifiers in MiniZinc do not get 'pre-declared', and may simply appear in a function signature. When lowering function items, we have to keep track of the type-inst identifiers and what positions they are used in to obtain the implied type-inst identifier 'declarations'.
Desugarings
The HIR involves several syntactic desugarings from the AST.
Note that only desugarings which cannot cause future errors referring to non-user written constructs to occur are permitted, in order to avoid emitting confusing error messages, or needing to track desugarings and specialise error messages for them.
For example, we could desugar 2D array literals into calls to
array2d
at this point, however, if the user were to use an invalid index set type, as in[|"a": "b": | 2: x, 3: y |]
, we would immediately rewrite that intoarray2d([2, 3], ["a", "b"], [x, y])
, which would give a type error indicating that no overload ofarray2d
exists for the given argument types, even though the user has not calledarray2d
at all.
Predicate/test items are rewritten into function items:
MiniZinc syntax | Desugaring |
---|---|
|
|
Unary/binary operators are rewritten as calls:
MiniZinc syntax | Desugaring |
---|---|
|
|
|
|
|
|
Generator calls are rewritten as calls with comprehension arguments:
MiniZinc syntax | Desugaring |
---|---|
|
|
String interpolation is rewritten using concat
and show
:
MiniZinc syntax | Desugaring |
---|---|
|
|
Analysis
Analysis of the HIR is generally done on a per-item basis. This allows us to avoid recomputation if an item hasn't changed (since even after lowering a model again, many items may still be the same). Additionally, analysis at this stage is designed to be as robust to errors as possible. For example, scope collection errors do not prevent type checking, and the type-checker tries to continue as much as possible in the presence of type errors.
Scope collection
Scope collection determines the identifiers in scope for each expression in an item (and also the identifiers in the
global scope). This can be accessed using the db.lookup_item_scope(item_ref)
query.
Note that this does not perform name resolution - that is done in the type-checker as identifiers in call expressions
cannot be resolved until the argument types are known (due to overloading).
We also check to ensure that variable declarations and function parameters only use irrefutable patterns (i.e. those which always match every value of the type).
See Scope collection for more detail about the process.
Type checking
Type checking is done for each HIR item, accessed through the db.lookup_item_types(item_ref)
query. It produces:
- A mapping from expression indices to their computed types
- A mapping from pattern indices to their computed types
- Note that these types are augmented to include more information about the pattern (e.g. so you can tell the difference between a pattern forming a variable declaration and a pattern that forms a type alias).
- A mapping from identifier expression indices to their declaring
PatternRef
- A mapping from identifier pattern indices to their declaring
PatternRef
(e.g. for enum atoms/constructor functions)
See Type checking for more detail about the process.
Case expression exhaustiveness checking
Case expressions inside items are checked to ensure that they are exhaustive.
For example
enum Foo = {A, B, C} ++ D(Bar);
enum Bar = {E, F};
var Foo: x;
any: y = case x of
A => 1,
B => 2,
D(E) => 3,
endcase;
Is missing the case for the pattern C
and the pattern D(F)
.
The algorithm is based on testing whether or not a pattern is 'useful' given a list of other patterns. If the wildcard
pattern _
is still useful given all the patterns that appear in the case arms, then the case expression is
non-exhaustive. See the documentation in pattern_matching.rs
for details.
Topological sorting
The topological sorter sorts the items so the declarations are not used in definitions/domains (other than function bodies) before they appear. This is where cyclic definitions are detected.
For example:
int: y = x + 1;
int: z = x - 1;
int: x = 3;
Will be reordered into:
int: x = 3;
int: y = x + 1;
int: z = x - 1;
Whereas
int: x = y;
int: y = x;
Has no ordering that allows definitions to appear before they are used. Therefore, this is an error.
However, this is allowed:
constraint y = x;
var int: x = y;
var int: y;
As it can be reordered into:
var int: y;
var int: x = y;
constraint y = x;
Which does allow every variable declaration to appear before being used.
Validation
Some final validation of the HIR is done at the whole-program level, since some problems may be spread across multiple files.
- Check for duplicate function definitions
- For each pair of function items
f1
andf2
with the same name, we consider it to be an error if the parameters off1
can be used to callf2
and vice-versa, and either:- Both have bodies, or
- They have different return types
- For each pair of function items
- Check for duplicate constructors
- Defining two constructor functions with the same name is not allowed
- E.g.
enum Foo = A(B) ++ A(C)
is illegal since there would be two constructors namedA
. - Note that it is legal to overload a constructor function using a normal function. In fact, the function may have exactly the same signature as the constructor, and will be used in preference to it.
- Check for multiple assignments to the same variable (if allowing multiple assignments to the same variable is not enabled)
- Check for multiple solve items
If no errors were emitted at all for the HIR module (or the syntax model), then the program is valid and we proceed to lower the program into the typed high-level intermediate representation (THIR).
Scope collection
Scope collection gives the identifiers in scope for each expression in an item. Note that at this point, we don't perform name resolution, as resolving call identifiers requires the types of the arguments to perform function overloading resolution, and resolving record field access requires knowledge of the accessed record's type.
It would be possible to only keep track of this for expressions which are identifiers, rather than all expressions (and we could even perform name resolution for variables and non-overloaded function calls). However, having the scopes computed for every expression is useful for providing code completion information in the language server.
Scope collection results
Scope collection results in a data structure containing an arena of Scope
s which contain the identifiers defined in
each scope in an item. Each Scope
keeps track of its parent Scope
index, so that the chain of scopes can be followed
to resolve an identifier. Each expression index in an item is mapped to a scope index and generation (see below for
details on generations).
The db.lookup_item_scope(item_ref)
query produces a ScopeResult
which allows for easy lookup of functions and
variables in scope for an expression. It automatically traverses the item scopes and will bubble up to global scope
to find an identifier. Note that the result of looking up identifiers is a PatternRef
(or list of them for overloaded
functions). These point to the declaring identifier pattern of the identifier (as there may be several identifiers
declared in a single item, via destructuring or in a let expression, so it is not sufficient to give only the item).
Scopes and generations
A Scope
keeps track of the variables and functions declared in a particular scope, along with the parent scope (if
any). For example:
function foo(int: a) =
let {
int: x = 3 * a;
int: y = x + 2;
} in a + y;
Has two scopes: one created by the function item for its body, and one created by the let item.
The first scope contains a declaration for the identifier a
and its parent scope (assuming this is a top-level item)
is the global scope. The second scope has declarations for the identifiers x
and y
, and its parent scope is the
first scope.
Then, the scope for the outer let would be computed to be the first scope, while the scope for the RHS of x
, the
RHS of y
, and the in
expression a + y
would be the second scope (and all of their sub-expressions would also
be assigned to be the second scope).
Note that items in let expressions are such that they only enter into scope once they appear, so an item cannot refer to an item later than itself. For example:
let {
int: y = 10;
int: z = let {
int: x = y;
int: y = 1;
} in x;
} in z
Will give z = 10
as the let item int: y = 1
is not in scope for the declaration of x
.
This is achieved by keeping track of the 'generation' where a let-item identifier has been declared. The generation is incremented every time we visit an identifier pattern which creates a new variable. It should also be noted that the patterns for a declaration in a let are always visited after processing their RHS. Expressions are then assigned both a scope, along with a generation defining the minimum generation an identifier must be to be considered available in the expression. Exiting a scope restores the generation the value it had upon entering the scope.
Following the example:
- The declaration
int: y = 10;
is visited- The RHS expression (
10
) is assigned scope 0, gen 0 - The LHS identifier
y
is assigned scope 0, gen 1
- The RHS expression (
- The declaration
int: z = let { int: x = y; int: y = 1; } in x
is visited- The RHS
let
expression is assigned scope 0, gen 1- The declaration
int: x = y;
is visited- The RHS expression
y
is assigned scope 1, gen 1 - The LHS identifier
x
is assigned scope 1, gen 2
- The RHS expression
- The declaration
int: y = 1
is visited- The RHS expression
1
is assigned scope 1, gen 2 - The LHS identifier
y
is assigned scope 1, gen 3
- The RHS expression
- The
in
expressionx
is assigned scope 1, gen 3
- The declaration
- The LHS identifier
z
is assigned scope 0, gen 1 (restored because exited scope 1)
- The RHS
- The
in
expressionz
is assigned scope 0, gen 1
From this, we can see that while the RHS of int: x = y
is scope 1, since its generation is 1, the y
does not refer
to the y
in scope 1 (which is generation 3), and so instead refers to the y
in scope 0 (which is generation 1).
It should be noted that the 'generation' values are an implementation detail - what actually matters in the end is to be able to determine whether or not an identifier is in scope or not for a given expression.
Patterns
Patterns used for variable or parameter declarations are only allowed to contain tuples, records and identifiers. Otherwise, the pattern is refutable and may not match all values. Identifiers in variable declarations always create new variables, and do not refer to enumeration/annotation atoms (instead they get shadowed).
enum Foo = {A, B};
% OK: creates variables `x`, `y` and `z`.
any: (x, (foo: y, bar: z)) = (1, (foo: 2, bar: 3));
% OK: shadows enum values `A` and `B`
any: z = let {
int: A = 1;
int: B = 2;
} in A + B;
% ERROR: refutable pattern may not match all cases.
any: (m, 1) = (3, 2);
In comprehension generators and case expressions, all patterns are allowed, and identifiers can either refer to enumeration/annotation atoms, or if none match, they create new variables. A problem with this approach is that since we don't have namespacing, a user could create an atom with the same name as a variable binding in a case expression or generator in another part of the code, and then that variable binding would instead match that atom, changing the behaviour (likely causing the model to fail type checking).
Type checking
The type-checker performs bottom-up typing of expressions, and checks that the types are correct. This is performed on a per-item basis. The type-checker deals with 'signatures' and 'bodies' of items separately. Signatures give the type information needed to compute the type of an expression referring to the item. Typing a body involves typing the rest of the item (generally this is the annotations and the RHS).
Consider the items:
function float: foo(int: x :: my_ann) = x + 0.5;
1..3: a = 23;
any: b = foo(a);
foo
has a signature offunction float: foo(int)
. Its RHS ofx + 0.5
is typed separately and verified against the signature return type, along with the annotationmy_ann
.a
has a signature ofint: a
, which is computed using only the LHS of the declaration as the type is complete. The RHS of23
is typed separately and verified against the signature type.b
uses anany
type and so its signature offloat: b
is computed using the RHS.
Type checker results
Type checking an item produces several maps containing the computed types. The TypeResult
provides access to all types
for an item, by using the signature types and the body types as needed. This can be obtained using the
db.lookup_item_types(item_ref)
query.
- Indexing using an
ArenaIndex<Expression>
gives theTy
of the expression.- E.g. the expression
{1, 1.5}
will have typeset of float
.
- E.g. the expression
- Indexing using an
ArenaIndex<Pattern>
gives thePatternTy
of the pattern.- The
PatternTy
includes information about what the pattern is being used for, rather than just the type of the pattern itself (e.g. so we can tell if a pattern is simply for destructuring, or if it actually declares a new variable). - E.g. the declaration
any: (x, y) = (1, 1.5)
will be such that(x, y)
is aPatternTy::Destructuring(tuple(int, float))
x
is aPatternTy::Variable(int)
y
is aPatternTy::Variable(float)
- The
- The
name_resolution(ident)
method finds thePatternRef
for an expression which is an identifier (e.g. identifier pointing to a variable). - The
pattern_resolution(ident)
method finds thePatternRef
for a pattern which is an identifier (e.g. enum atom used in a case expression pattern)
Expressions
Typing of expressions is done recursively, using the types of child expressions to determine the type of the outer expression.
For example, the expression ([1, 2.5], 3)
will be typed by:
- The first expression is a tuple, so visit its child fields
- The first tuple field is an array literal so visit the child members
- The first child member is an integer literal, so its type is
int
- The second child member is a float literal, so its type is
float
- The first child member is an integer literal, so its type is
- Therefore the array literal is determined to have type
array [int] of float
as float is the most specific supertype ofint
andfloat
. - The second tuple field is an integer literal, so its type is
int
- The first tuple field is an array literal so visit the child members
- Therefore the tuple is of type
tuple(array [int] of float, int)
Calls
One exception to bottom-up typing occurs when dealing with calls, as in order to perform overloading resolution, we must specially handle the case of a call with an identifier callee. See the following section on function resolution for more detail.
For example, the call foo(1, "a")
would be typed by:
- Getting the types of each argument (in this case
int
andstring
) - Finding all the function items named
foo
- Performing overloading resolution given the argument types
foo
is given the type of the operation- The call is given the return type of the operation
If the call does not have an identifier as its callee, the callee type is determined in the usual bottom-up fashion, and no overloading resolution is required.
Identifiers
Identifier expressions cause the type checker to lookup the identifier in the expression's scope, and fetch the type signature of the retrieve pattern's item to determine the type of the identifier. For bodies, we can fetch the signature of the item directly (and reuse the computation of that signature if it's already done), but for signatures which depend on other signatures, we do not do this because of the possibility of cycles.
Instead, the signatures of the dependencies are always computed, and if we reach the initiating signature during this process, we can break out of the cycle and return the error type (at that stage, not at the initiating site). Any cyclic definition errors will be emitted later during topological sorting. This is important for dealing with function signatures that include calls to other overloads of the function, since these should work, but resolving the overloaded call would require the type of 'this' signature, which is in the middle of being computed.
Ascribed types
Typing user-written types (the HIR Type
) involves computing the concrete type (Ty
) they represent. For example, the
type var 1..3
is typed by computing the type of the domain 1..3
. Since it is a set of int
, then the variable is an
int
type, and since the type has the var
modifier, the complete type is computed to be var int
.
Typing these becomes more complex when dealing with incomplete types. In these cases, we always have a RHS expression which can be typed first, and then we can use this computed type to fill in the 'holes' in the ascribed type.
For example, consider the (contrived) declaration:
array [_] of var set of any: x = [{1, 2}];
In this case, we start by computing the type of the RHS, and determine it to be of type array [int] of set of int
.
This type is unified with the ascribed type to obtain the type array [int] of var set of int
for x
.
Patterns
Typing of patterns is done top-down, based on an already computed type that the pattern must take. For example, to type the declaration
tuple(var int, any): (a, b) = (1, 2.5);
- The concrete type of the declaration is
tuple(var int, float)
(found by computing the type of the RHS and using it to fill in hole in the LHS type) - Therefore, the root pattern
(a, b)
has typetuple(var int, float)
. - We then visit the child patterns and set their type to the corresponding tuple field type.
- So
a
has typevar int
- And
b
has typefloat
One complication is that when dealing with constructor calls in patterns, we only know the 'return type' of the call, as we know the type of the call pattern itself, but not its arguments. We use this return type to determine the what the actual constructor call must be. For example:
enum Foo = A(Bar) ++ {B, C};
enum Bar = {D, E};
var Foo: x;
any: y = case x of
A(v) => v,
_ => E
endcase;
- The type of
x
isvar Foo
- Therefore the type of the first case pattern
A(v)
is alsovar Foo
- So based on that, we must be using the constructor
var Foo: A(var Bar)
, and so this is the type of theA
pattern - The type of
v
is thereforevar Bar
- So the result for the first case arm is
var Bar
- So based on that, we must be using the constructor
- The second case pattern
_
should also match the type ofx
(var Bar
)- Its result type is
E
, which is the typeBar
- So the case expression is of type
var Bar
- Its result type is
Function resolution
Function resolution involves determining a call identifier and a list of argument Ty
s, the best matching overload to
use (if there is one). The algorithm used is:
- If there is a variable which is an
op
function with the correct name, use it. - If not, then find the function items in scope with the correct name.
- Remove any candidates which have an incorrect number of arguments.
- Remove any candidates which cannot be instantiated with the argument
Ty
s - For every pair of candidates
a
, andb
remaining, eliminate the 'less specific' function- Instantiate
a
andb
with our call's argument types - If the instantiated argument types of
a
are all subtypes of the instantiated argument types ofb
, but not the other way around, then eliminateb
(and vice-versa). - If both instantiated argument types are equivalent, then
- If
a
is polymorphic butb
is a concrete function, then eliminatea
(and vice-versa) - If both candidates are polymorphic, then
- If
a
can be instantiated withb
's (original) parameter types, but not the other way around, eliminatea
(and vice-versa)
- If
- If
a
has a body butb
doesn't, eliminateb
(and vice-versa)
- If
- Otherwise arbitrarily eliminate
a
- Instantiate
Essentially, we choose such that:
- More specific parameter types are preferred:
bool
overint
,int
overvar int
. We take the instantiated argument types into account, so a function taking$$E
will be preferred over a function takingfloat
if called with anint
argument. - Where two candidates would instantiate to the same types, prefer
$$E
functions over$T
functions, and prefer concrete types over either of those.
It is an error if we are left with more than one candidate, or no candidates at the end of the process. Additionally, it should be noted that in the case of two identical functions which have bodies, we will simply arbitrarily choose one at this stage to allow type checking to continue with a reasonable return type. The duplicate function error will be emitted during final validation of the HIR.
Type-inst variables
There are two kinds of type-inst variables: $T
and $$E
. $T
matches any type other than arrays and functions (this
is because the old compiler doesn't accept arrays for these, but actually it seems like it would make more sense if it
did). $$E
matches any enumerable type (booleans, integers, enums) - unlike the old compiler, this is a a properly
generic type parameter, and is not considered to a special int
.
It's also possible to restrict $T
to accept only types valid for array indices if it is used as array [$T] of int
.
In this case only integers/enums or tuples of them would be accepted.
One complication is that the way type-inst variables are used looks different to most other languages. Since omitting
var
/opt
implies par
, non-opt
, a parameter declared to be $T
is actually par (non-opt) $T
. That is, the
parameter will accept the par, non-optional version of whatever type the type parameter $T
is given as. any $T
is
used to make the parameter accept the actual type of the type parameter $T
.
Note that the language is currently missing anyvar
and anyopt
, which would be needed for expressing parameters
whose inst/optionality depend on the input type parameters (for type-inst variables used in parameters only, this is
not really a problem since using var $T
or opt $T
will still let you match the needed combinations - the problem
is that if you use the type-inst variable in the return type, specifying opt $T
will force the return type to be
opt
, even if $T
is non-optional).
Determining the types substituted for type-inst variables
Since calls do not explicitly set the types to give for each type-inst variable, we have to use the argument types to determine them. Consider the function:
function var opt $T: foo(var $T: x, any $T: y);
var 1..3: p;
opt int: q;
any: v = foo(p, q);
Then for the call foo(p, q)
we start by considering the first argument p
, which is of type var int
. The function
parameter is declared as var $T
, so that means it applies its own inst (var
) and optionality (non-opt
) to the type
of $T
- therefore we strip off the inst and optionality from the given argument type and use that for $T
. So
$T
for this argument is set to be (par
, non-opt
) int
.
Next we look at the second argument q
, which is of type opt int
. Since the function parameter is declared as
any $T
, it does not apply any modifiers to the type of $T
, so we set the type of $T
for this argument to be
opt int
.
Then the final type for $T
is the most specific supertype of int
and opt int
, meaning the final type is opt int
.
From this, we can compute the actual instantiated function signature by substituting $T
= opt int
for the parameters
and return type.
- The first parameter
var $T
becomesvar int
(again, sincevar $T
is reallyvar
, non-opt
$T
). - The second parameter of
any $T
givesopt int
(sinceany
means don't apply any modifiers to$T
). - The return type of
var opt $T
givesvar opt int
(since it applies thevar
andopt
modifiers to$T
).
So the instantiated result is:
function var opt int: foo(var int: x, opt int: y);
var 1..3: p;
opt int: q;
var opt int: v = foo(p, q);
Output typing
The expressions of output items, and definitions of :: output_only
declarations are type-checked in a special mode
where the types of top-level identifiers are assumed to be par. This alleviates the need to manually insert calls to
fix()
.
In this example, if we don't consider p
in the output statement to be par
, then the if-then-else will cause will
trigger a type error as it will return the illegal type var string
.
var bool: p;
output [if p then "Yes" else "No" endif];
Typed high-level intermediate representation (THIR)
The THIR is used as a transition phase between the HIR and the MIR. It progressively rewrites the IR into lower-level constructs until it can be readily lowered into MIR. Compared to HIR, the THIR is:
- Combined (there is only one
Model
which contains all of the items from all model files) - Fully typed (expressions always have full, correct types - we abort compilation if there were errors)
- Destructured - declaration items can only declare a single identifier
- Fully resolved (identifiers do not have their own names, they simply point to the item they are for)
- Desugared (since type information is now available, we can perform more desugarings)
- Expressions are boxed rather than arena allocated
The language constructs made available in THIR are an attempt to provide rewriting stages with a convenient representation which is not too high-level and complex to process, but not so low-level as to be cumbersome to use.
Structure
The root THIR node is the Model
which contains arenas for each kind of item. Since there is only one Model
, the
item indices now globally identify the items in the program. Each item contains storage for the expressions it owns in
its ExpressionAllocator
. Expressions contain their types, which are computed during construction. A builder API is
provided (ExpressionBuilder
) for creating expressions.
Lowering from HIR
Lowering involves adding the items from the HIR into the THIR model. This is done in topologically sorted order, so we only have to visit each item once. One exception is that functions with bodies are lowered in two stages: first, the function signature is added, then the body is added to it after all items have been processed. Otherwise identifiers in the function bodies could refer to items not yet added to the THIR.
Desugarings
The THIR involves several semantic desugarings from the HIR.
Destructuring variable declarations are rewritten using multiple declarations:
HIR syntax | Desugaring |
---|---|
|
|
Type aliases are removed as they are resolved:
HIR syntax | Desugaring |
---|---|
|
|
2D array literals are rewritten using array2d
:
MiniZinc syntax | Desugaring |
---|---|
|
|
|
|
Indexed array literals are rewritten using arrayNd
:
MiniZinc syntax | Desugaring |
---|---|
|
|
|
|
Array access is rewritten into a call to '[]'
:
HIR syntax | Desugaring |
---|---|
|
|
Slicing is rewritten using mzn_slice
function calls:
HIR syntax | Desugaring |
---|---|
|
|
|
|
Performing a tuple/record access on an array is rewritten to perform the field access on each element of the array using a comprehension:
HIR syntax | Desugaring |
---|---|
|
|
|
|
Case expressions are rewritten such that the destructuring is moved into the branch RHS, and pattern identifiers which
create new variables are replaced with the wildcard _
pattern:
HIR syntax | Desugaring |
---|---|
|
|
Pattern matching in comprehension generators is rewritten using a case expression in the generator's where
clause.
Destructuring is rewritten as assignment generators:
HIR syntax | Desugaring |
---|---|
|
|
Model transformations
At the THIR level, several model-to-model transformations occur which progressively remove language constructs which do not exist in the mid-level intermediate representation.
The order of these transforms is often important as certain information or language constructs may be removed in one transform, and so cannot be used in subsequent transforms.
The transforms which occur are:
- Output item removal
- Generating constraints for domains
- Top-down typing
- Type specialisation
- Removal of overloading
- Erasure of records
- Erasure of enums
- Desugaring of comprehensions
- Erasure of option types
- Desugaring of capturing
See Model transformations for more details.
Model transformations
- THIR seems to be a good fit for model level transformations.
- The idea would be to perform some sort of rewriting of the THIR tree to produce a new tree
Output item removal
The MIR
does not have output items. Instead, we create
:: output_only
string declarations for each output section.
output ["foo\n"];
output :: "section" ["bar\n"];
output :: "section" ["qux\n"];
Becomes:
string: mzn_output :: output_only = concat(["foo\n"]);
string: mzn_output_section :: output_only = concat(["bar\n"] ++ ["qux\n"]);
We also make the :: output
variables explicit, so var
declarations with no
RHS definitions get :: output
added unless they are already marked as
:: output
or :: no_output
.
Rewriting of domains to constraints
This transform rewrites domains into constraints where necessary.
The MIR only allows domains in the type-insts of fresh variables without RHS definitions which do not contain structured types (or nested arrays).
For example, var 1..3: x
, var set of 1..3
, array [1..3] of var 1..3
, array [1..3] of var set of 1..3
.
Other type-inst domains, such as function parameter domains, function return type-inst domains, domains of par
declarations, and domains of declarations with RHS definitions, are transformed into
constraints which enforce the domains (or assertions if the declaration is par
).
1..3: x;
Becomes:
int: x;
constraint assert(x in 1..3, "Value out of range");
Structured types and arrays need to check domains for each element:
tuple(1..2, 1..3): x;
array [int] of 1..3: y;
Becomes:
tuple(int, int): x;
constraint assert(x.1 in 1..2 /\ x.2 in 1..3, "Value out of range");
array [int] of int: y;
constraint forall (y_i in 1..3) (y_i in y);
The actual implementation also has to ensure that the semantics of when the domain gets evaluated remains consistent
with what's expected, so some extra let
expressions are often needed to prevent re-evaluation.
Since we also would like to present the user with a useful error/warning message, we also keep track of how to display
the variable accessor (e.g. x.1.foo
for tuple(record(1..3: foo)): x
) and use the mzn_domain_constraint
functions
to perform the checks.
Unpacking of structured type declarations
Consider the declaration:
tuple(var 1..2, var 1..3): x;
Since the type-inst is for a tuple type, we must rewrite this to not have a domain.
However, since this has no RHS, we do not have to introduce constraints - instead we can unpack x
into its constituent variables:
tuple(var int, var int): x = let {
var 1..2: a;
var 1..3: b;
} in (a, b);
In the case of an array of structured types, a comprehension can be used. For example:
array [1..3] of tuple(var 1..2, var 1..3): x;
Can be transformed into:
array [1..3] of tuple(var int, var int): x = [
let {
var 1..2: a;
var 1..2: b;
} in (a, b)
| _ in 1..3];
Index set checking
For arrays, each declared index set needs to be checked against the true index set of the array.
array [1..3, int] of int: x;
Becomes:
array [int, int] of int: x;
constraint assert(index_set_1of2(x) = 1..3, "Index set doesn't match");
Since we again need to produce a useful error message, we perform the index set checks using the mzn_check_index_set
function, which can produce an error message mentioning which dimension has the incorrect index set.
Top-down typing
Top-down typing of the model involves pushing types from enclosing expressions into their subexpressions.
This allows us to determine a real type for every type found to be bottom
during bottom-up type-checking.
For example, in the expression [1, <>]
, bottom-up typing would give:
1
is anint
<>
is anopt bottom
[1, <>]
: is anarray [int] of opt int
In top-down typing, we start from the outer-most expression and work inwards:
[1, <>]
: is anarray [int] of opt int
(found from bottom-up typing)1
: is anopt int
(because the element type of the enclosing array isopt int
)<>
: is anopt int
(because the element type of the enclosing array isopt int
)
This allows us to remove the bottom
from the MIR entirely.
For a declaration item, the top-down type of the outer-most RHS expression is taken to be the declared LHS type from the declaration.
For example, in tuple(opt int, float): x = (3, 5)
:
(3, 5)
is atuple(opt int, float)
(taken from LHS)3
is anopt int
(first field of tuple)5
is afloat
(second field of tuple)
The top-down type of a call argument is taken to be the declared type of the parameter.
For example:
function int: foo(opt int: x);
int: y = foo(3);
foo(3)
is anint
(taken from the LHS ofy
)3
is anopt int
(taken from the parameterx
of the functionfoo
)
Polymorphic types
For a polymorphic function which with a type-inst variable in the return type, we may need to use the return type to determine the type of the parameters:
For example;
function array [$X] of any $T: foo(array [$X] of any $T: x);
array [int] of int: y = foo([]);
foo([])
is anarray [int] of int
(taken from the LHS ofy
)- For this call, the return type
array [$X] of any $T
is thereforearray [int] of int
- So
$X = int
and$T = int
- Therefore the parameter
x
isarray [int] of int
- So
[]
is anarray [int] of int
(taken from the parameterx
of the functionfoo
)
Making coercion to option types explicit
Since option types are going to be transformed into tuples, we must keep track of values which are known to occur, but are used as optional values. These coercions must be made explicit since one the option types become tuples, the non-tuple occurring type will no longer be a subtype of the optional one.
Type specialisation
Type specialisation involves generating concrete versions of polymorphic functions for each call to such a function. This is needed because the MIR does not have polymorphic functions.
We start by finding the top-level polymorphic calls, and then create the concrete versions if we haven't already. We then traverse the bodies of the functions and recursively specialise polymorphic calls in them.
The end result is that all reachable calls are now non-polymorphic.
We can then remove all polymorphic which have bodies.
Calls to polymorphic functions without bodies are left as-is and not specialised since they would be interpreter builtins.
Example
function any $T: foo(any $T: x) = bar(x);
function any $T: bar(any $T: x) = x;
any: a = foo(1);
any: b = bar(2);
First we process the call foo(1)
and generate
function int: foo(int: x) = bar(x);
Then we process the call in the body of the newly generated foo
, which is bar(x)
, and generate
function int: bar(int: x) = x;
Then we process the call bar(2)
, but don't need to generate anything since we already generated an
integer version.
The final result is
function int: foo(int: x) = bar(x);
function int: bar(int: x) = x;
any: a = foo(1);
any: b = bar(2);
Dynamic dispatch
In order to facilitate dynamic dispatch, we need to ensure that when there are multiple compatible polymorphic overloads of a function, we instantiate all versions which are more specific that the one that has been called. Otherwise, the more specific versions won't be in the model which we create the function dispatch preambles.
For example, consider:
function int: foo(any $T: x) = 1;
function int: foo(var $$E: x) = 2;
function int: foo($U: x) = 3;
var opt 1..1: x;
constraint foo(x);
If we only instantiated the var opt int
version of foo(any $T)
, then we would not be able to
dispatch to the other versions at runtime.
To do this, we find the type-inst var instantiations for each polymorphic overload (other than the original) given the argument types, and then instantiate calls using those types.
In this case, we have two other overloads and an argument of var opt int
:
foo(var $$E)
gives$$E = int
, and so the signature becomesfoo(var int)
.foo($U)
gives$U = int
, and so the signature becomesfoo(int)
.
We then instantiate foo(var int)
and foo(int)
and will be able to generate function dispatch
preambles as required.
Note that it's possible for multiple overloads to give the same signature - so we actually have to
re-lookup the best matches to prevent instantiating the wrong versions. For example, if the second
overload was foo($$E)
, then both foo($$E)
and foo($U)
would give foo(int)
to instantiate,
and in that case, we need to use the more specific $$E
version and not the $U
one.
Finally, we also have keep track of which polymorphic function each specialised function comes from, because dynamic dispatch should not happen between different instantiations of the same polymorphic function.
Generation of show
for erased types
Enums, option types and records (and types containing them) need to have specialised versions of
show
generated. The definition of show
for plain enums is actually generated during
enum erasure, but the others are generated at this stage.
Optimisation for enum functions
In the future, it would be possible to add an optimisation for functions which are polymorphic over
enum types. Functions which take a $$E
can actually just specialise into the type-erased int
version if the call does not lead to a show($$E)
call somewhere down the line.
However, it should be noted that since the fzn_...
functions are never polymorphic, there probably
aren't that many layers of depth to most calls.
Looking up function calls after specialisation
Once specialisation has occurred, we it's no longer valid to perform function lookups on non-builtin polymorphic functions (as we would have had to specialise them in order to use them correctly).
So all calls introduced after this stage must either be to builtins, or to concrete functions (or exactly matching an existing specialised version).
Generation of function dispatch preambles
The MicroZinc Interpreter only supports static dispatch. Therefore, to emulate dynamic dispatch to more specific versions of functions, we can add preambles to the bodies of functions which dispatch to the other versions.
Dispatch follows this hierarchy:
flowchart TD vot["var opt T"]-- occurs is fixed to true -->vt["var T"] vt-- variable is fixed -->t["T"] vot-- variable is fixed and occurs -->t vot-- variable is fixed -->ot["opt T"] ot-- variable occurs -->t
We consider a directed graph where the nodes are each function. Then, for each pair of overloads of a function, if the parameters of one can all dispatch to other (or are the same type), then we add this as an edge.
Then by getting the transitive reduction, we can determine a list of functions each function needs to dispatch to.
Erasure of records
Erasing records into tuples involves transforming record literals into tuples, and record access into tuple access, ensuring that the order of the fields remains consistent.
record(int: foo, float: bar): x = (bar: 1.5, foo: 2);
int: y = x.foo;
Transforms into
tuple(float, int): x = (1.5, 2);
int: y = x.2;
Erasure of enums
Desugaring of comprehensions
Erasure of option types
Removal of overloading
Since MIR
does not support overloading, we perform name-mangling to give each
overloaded variant of a function a distinct name.
Desugaring of capturing
Compilation to MicroZinc
Compilation of the MiniZinc THIR to MicroZinc will involve transformation to a new IR for MicroZinc.
Decomposition of variable conditionals
if-then-else
expressions with a variable condition need to be rewritten into
function calls.
Lifting partiality
Partial functions need to transformed into total functions.
Subtype based overloading
As functions arguments also accept their subtypes, these should be transformed to dispatch to their specialised counterparts.
Pass values from outer scopes of functions through parameters
Functions which refer to variables outside the scope of the function need to be transformed such that these variables are passed as arguments to the function.
Generation of the main entrypoint
MicroZinc uses a top-level function main
as its entrypoint for the interpreter.
The top-level decision variable and constraints are added as a let
expression
in this function, taking the model parameters as arguments.
Totalisation
Overview
A function is total iff it is defined for any argument in its domain. In MiniZinc, the domain of a function is determined by the type-insts of its arguments, but not by any constraints on its arguments.
A function is partial iff it is undefined for certain arguments in its domain. In MiniZinc, a function is partial if its return type is not a subtype of var opt bool
, and at least one of these conditions holds:
- If at least one of its arguments has a constrained type. E.g.,
function int: foo(1..3: x) = x + 1;
is partial, because its argumentx
is constrained. - If its body is a partially defined expression
- If it is a built-in function that is known to be partial (e.g. integer division, modulo etc.)
A function that is partial according to the rules above can be annotated as ::promise_total
if it can be guaranteed that it will return a value for any input in its domain.
An expression is partially defined if its type is not a subtype of var opt bool
and it does not evaluate to a value for all possible evaluations of its free variables. At least one of the following conditions needs to hold for an expression to be partially defined:
- It is a call to a partial function or built-in operator
- It is a
let
expression with a type that is not a subtype ofvar opt bool
, and one of the cases in thelet
is aconstraint
, or one of the variables in thelet
has a constrained type-inst - One of its immediate subexpressions is partially defined
Array types
Two options:
- An undefined element in an array makes the entire array undefined (MiniZinc 2 semantics)
- An undefined element in an array only causes undefinedness if the element is accessed (Stuckey/Frisch paper)
Issues with option 2: Passing an array to a function now means that all arrays have to be arrays of tuples (even if all elements are statically known to be defined); or, we have to generate different versions (tuple and non-tuple) for each function, for all array argument types.
Transformation
The basic idea of the transformation is to turn any partially defined expression into a pair of expressions (b,e)
where b
is a Boolean that is true iff the expression is defined, and e
is the value of the expression if it is defined, and ⊥
otherwise.
- The return type of a partial function returning type
T
is changed totuple(bool, T)
. - Any variable declaration of type
T
whose right hand side is a partially defined expression is changed into a variable declaration of typetuple(bool, T)
. NOTE: What about array types? Do they capture definedness for each element, or for the array? - Set literals (containing partially defined expressions)
The type is transformed from
set of T
intotuple(bool, set of T)
as follows.{ e1, e2, pde1, ..., pdek, ... }
intolet { any: (b1,tmp1) = pde1; any: (bk,tmpk) = pdek; } in (forall([b1,...,bk]), {e1, e2, tmp1, ..., tmpk ...})
- Array literals (containing partially defined expressions)
Change typeChange typearray[...] of T
intoarray[...] of tuple(bool, T)
and change[ e1, ..., pde1, ... pdek, ... en]
into[ (true, e1), ..., pde1, ... pdek, ... (true,en) ]
array[...] of T
intotuple(bool, array [...] of T
and change[ e1, ..., (b1, pde1), ... (bk, pdek), ... en]
into(b1 /\ ... /\ bk, [e1, pde1, ... pdek, ... en])
- Array comprehensions
- Partially defined generators are not permitted (static type error).
- Partially defined generated expressions are fine. The resulting type changes from
array[...] of T
intotuple(bool, array[...] of T)
(like array literals). May need to createarray [...] of tuple(bool, T)
first, then extract the definedness from that.**** - Partially defined where clauses are fine (they are their own Boolean context)
- These examples show why it would be a bad idea to allow partial generators. - par comprehensions
[ pde | i in 0..10, j in x[i] ]
[ pde | i in 0..10, (b,e)=x[i] where b, j in e ]
- var generator comprehensions:var 0..10: x
[ pde | i in 0..10, j in i div x..100]
[ let { any: (b,t) = i div x..100; any: (eb, et) = pde } in if eb /\ b /\ j in t..100 then (true,et) else (b,<>) endif) | i in 0..10, j in ub(i div x..100) ]
- Set comprehensions
These are transformed into array comprehensions and
array2set
in a previous compiler phase. - Array access
var
array access turns intoelement()
which has the right semantics.function tuple(bool, any $T): element(array [int, ...] of any $T: x, int: i, ...)
where the boolean is if then else endif
Can't use a normal function since the arguments would escape into the boolean context outside, so instead define the function which returns the pair:
function tuple(var bool, var $T): if_then_else(array [int] of var bool: c, array [int] of tuple(var bool, var $T): r)
- Tuple field access
Same as for arrays, so undefined inside a tuple makes the whole tuple undefined - Calls Transformed to return a tuple if not known to be total
let { ... } in
If not in::promise_total
/root context and non-boolean then the conjunction of the constraints and the variable definedness becomes the let's definedness
becomeslet { var int: x = e1; constraint c; } in e2
let { tuple(var bool, var int): x' = (de1, e1'); var bool: c' = c; } in (de1 /\ c' /\ de2, e2')
- Annotations Annotations are compiled in the root context.
- Output
The expression in an output statement must be total. It is a static type error if it isn't. Users can use
default
to make all expressions total.
Context analysis
Bytecode generation
Output generation
Some kind of code which maps from FlatZinc output variables to user model
output should be generated similarly to how we generate .ozn
output models
currently.
This functionality could be extended to allow for mapping of other solver information such as statistics, duals, etc to report these in the context of the user model.
Bytecode interpretation
MicroZinc specification
MicroZinc is a simple language used to define transformations to be performed
by the interpreter. It is a simplified subset of MiniZinc. The transformation
are represented in the language through the use of function definitions. A
function of type var bool
, describing a relation, can be defined as a
native constraint. Otherwise, a function body must be provided for
rewriting. The function body can use a restriction of the MiniZinc expression
language. An important difference between MiniZinc and MicroZinc is that a
well-formed MicroZinc does not contain partial expressions. Any partiality in
the MiniZinc model must be explicitly expressed using total functions in
MicroZinc. As such, constraints introduced in MicroZinc let expressions can
be globally enforced. They are guaranteed to only constrain the decision
variables introduced in the same let expression.
TODO: Remaining questions
- What annotation operations should be allowed in MicroZinc?
- The operational semantics for array access definition only works for 1-dimensional arrays. How can we generalise them for multiple dimensions?
- Add list of built-in functions
Syntax
MicroZinc is defined using the following syntax,
The Typing Rules of MicroZinc
The following syntax describes the types available in MicroZinc. The type syntax are used directly in let-expressions and parameter declarations.
Importantly, MicroZinc includes two types of sub-typing. When type \( T_1 \) is a sub-type of type \( T_2 \) then \( T_1 \) can be used anywhere where the type \( T_2 \) is required.
- In MicroZinc, \( \mathsf{par}~T \) is a sub-type of \( \mathsf{var}~T \).
- MicroZinc also has numeric subtyping (i.e., \( \mathsf{par~bool} \) is a subtype of \( \mathsf{par~int} \), which is a sub-type of \( \mathsf{par~float} \), and similarly \( \mathsf{var~bool} \) is a sub-type of \( \mathsf{var~int} \), which is a sub-type of \( \mathsf{var~float} \))
The \( \mathsf{pred} \) type is a special value given to expressions that enforce constraints, but do not return a value. This It should be noted that the
The following rules describe the conditions under which a MicroZinc program is correctly typed. In these rules the variable \( \Gamma \) will denote the typing context. This context contains known types for identifiers.
Functions and calls
At the top level of the MicroZinc program we find different functions. The program is well-typed if the type of the body of each function matches the declared type of the function given the declared types for the arguments. The types of all functions are available when typing the body expression of a function, allowing for (mutual) recursive functions.
Calls are defined in both the context of a constraint and on the right hand side of an assignment of a let-expression. In both cases the typing of call is described by the following rule.
Let expressions and identifiers
Identifiers are typed simply using a lookup in the typing context. The typing of the let expression iteratively adds the types of each declaration item to the typing context. The program is well-typed when all expressions on the right-hand side of a declaration match their declared types, and any constraint items are of \( \mathsf{pred} \) type.
Comprehensions and Generators
As shown in the following rules, the \( \mathit{genExpr} \) rules will must have either type \( \mathsf{set~of~int} \) or \( \mathsf{array1d~of}~T \). The \( \text{T_Comp} \) rule, to type array comprehensions, will use the \( elem \) function which maps the former type to \( \mathsf{int} \) and the latter to \( T \).
Arrays, Sets, and Tuples
MicroZinc has three different container types. Arrays can contain multiple, possibly duplicate, elements of the same type, each associated with a unique index with which the element can be retrieved.
Sets contain a certain number of unique elements of the same type. Ranges of elements are also typed as sets.
Finally, tuples are collections of elements with possibly different types. The number of elements in a tuple is known during type checking
If-then-else Expressions
Literals
The remaining parts of the MicroZinc laguage are simple literals that have an intrinsic type.
Type Instances
The remaining variants (\( \mathit{primType} \)) are trivial, and their expression directly describes their type.
The Operational Semantics of MicroZinc
Note that only function calls and the items in let expressions change the environment.
Function calls
TODO: The following rule comes from my thesis to call a native constraint, but I'm not sure if it works correctly when you have a functionally defined Boolean variable (call on RHS).
Let expressions
Constraint in let-expression are aggregated and bound to the returned variable. As such, let expressions are evaluated with an addition context collection \( C \), which contains the constraints enforced in the let-expression. Evaluation rules that use this special context are indicated using \( L \).
Comprehensions and Generators
Tuples and Arrays
If-then-else expressions
Identifiers and Literals
The MicroZinc Interpreter
Builtin functions
The following functions have been included as builtin functions for
MiniZinc Syntax Changes
New syntax includes:
- Tuples
- Records (which are transformed into tuples)
case
expression with complex pattern matching (possibly compiled intocase
with simple matching)- Namespacing/module system
Syntax still to be discussed:
- TypeInst variables syntax
- Function type syntax
- Module syntax
Error handling
Good error reporting should be integral to the compiler.
We need to think about what errors could be encountered and what information is needed to generate a useful message.
MiniZinc <-> MicroZinc
- Could keep a stack of the transformations, so we always know the origin of any new constraints
- Transformations generate explanations of why they were done, so the logic can be followed for debugging
Bytecode
- Could generate bytecode with debugging symbols giving locations for instructions
- Interpreter could have a debugging runtime mode enabling full tracing