Skip to content

Architecture

Kevin Giovinazzo edited this page May 21, 2024 · 10 revisions

Facade

An ErgoFacade is an immutable object that defines an Ergo environment. The standard Ergo environment is provided by ErgoFacade.Standard.

The Facade exposes builders and utility methods to:

  • Configure the Parser
    • By adding Abstract Parsers (AddAbstractParser, AddParsersByReflection)
  • Configure the Interpreter
    • By adding libraries (AddLibrary, AddLibrariesByReflection)
  • Configure the Shell
    • By adding Shell Commands (AddCommand, AddCommandsByReflection)
    • By routing its I/O streams (SetInput, SetOutput, SetError)
  • Configure the Virtual Machine
    • By routing its I/O streams (SetInput, SetOutput, SetError)

Parser

Parsing is implemented by the class ErgoParser, a monadic top-down parser which handles all of the primitive types and exposes helpers to handle Abstract Terms. The parser is built on top of the ErgoLexer and supports unicode streams.

The parser is most commonly accessed through the InterpreterScope, which exposes a Parse<T> method. This is because the parser needs to be aware of any user-defined operators that were declared in the current module and in its import tree.

The parser is also managed internally by the Interpreter as it loads modules from disk.

Abstract Terms

(See also: Abstract Terms)

Abstract Terms are custom Ergo objects specified and implemented in C# and bundled with their own AbstractTermParsers.

Abstract Terms implement the ITerm interface through the AbstractTerm base class, overriding standard behavior like unification and substitution and enabling the efficient representation of types such as Lists, Tuples, Sets and Dicts.

Interpreter

The ErgoInterpreter is primarily responsible for loading modules from disk and creating an InterpreterScope, an important structure that represents the current module tree and encapsulates error-handling and event-forwarding logic. It is also necessary to create Knowledge Bases.

Loading a module is divided in two steps: first, the Directives are parsed and executed. This includes operator declarations and module imports. Then, the module is parsed and its internal knowledge base is populated. At this point, a ModuleLoadedEvent is raised, notifying all libraries that are visible from the current scope. Later on, when the InterpreterScope is finalized, it can be used to create a proper knowledge base and raise the KnowledgeBaseCreatedEvent which will trigger expansions and the compiler.

For example, the Tabling library defines a tabled/1 directive and responds to the ModuleLoadedEvent (among others). By using the directive to mark specific predicates, it is then possible to transform those predicates at a later point (in this case, as soon as they are loaded).

Knowledge Base

A specialized database-like structure that stores predicates, some of which may be dynamically added or removed at runtime. It exposes methods such as AssertA, AssertZ, Retract, RetractAll and GetMatches.

Exceptions

Exceptions are handled by the ExceptionHandler which is configured at the InterpreterScope level. In a standard shell environment, the exception handler will print all exceptions to the console. It is also possible to configure an exception handler that filters and logs or rethrows any caught exception, depending on the circumstances.

The ExceptionHandler needs to be explicitly referenced when performing an action that might throw. It exposes methods like Try and TryGet<T> that can be used to encapsulate the relevant snippets.

Libraries

Libraries are the C# counterpart to Ergo modules. A library is scoped to a module and has two main responsibilities:

  • Exporting directives and built-ins
  • Reacting to events forwarded by the InterpreterScope

Libraries can be added at the Facade level, at the Interpreter level and at the Module level depending on how much granularity is needed. It should be noted, however, that once a query is compiled it will target whatever libraries were available to it at the time of compilation.

Interpreter Directives

Directives inform the interpreter and their parent library on how to load modules. They modify the InterpreterScope directly, and are executed while a module is being loaded.

You can create custom directives and export them through a library, at which point they will be available to all modules referencing the library's module.

Built-Ins

Built-Ins are predicates written in C# that can be compiled against the ErgoVM. Like directives, they are exported by a library. Built-Ins have some advantages over standard Ergo predicates:

  • They can be variadic (up to N arguments)
    • Right now, N is a constant equal to 255 but that might change in the future
  • They can be optimized, sometimes even optimized away, with ad-hoc logic
  • They are then compiled down to efficient and specialized C# delegates that can exploit the VM to its full extent
    • This includes special facilities like Solution Generators (which optimize large iterators, like for/4 does)
  • The compiled Ops are cached before runtime, and can be reused both at runtime and to speed up query compilation

It should be noted that most Ergo code is compiled down to control flow, built-in calls and cyclical calls. So there is virtually no overhead to writing Ergo predicates, especially if they're tail-recursive; but sometimes you need to implement some higher-level or side-effecting logic, or some particular optimizations -- that's when you want to write a built-in.

Predicate.FromOp

It is also possible to create predicates that will always compile to the specified Op via Predicate.FromOp. These predicates are analogous to anonymous built-ins, but due to their nature they can't be optimized at the graph level. However, they also don't come with the usual boilerplate that prepares the VM's registers before making a call, which makes them faster in some scenarios.

Events

A very simple event system is made available through the InterpreterScope which, acting as a repository of modules, signals all visible libraries when a particular ErgoEvent is raised. Libraries are then responsible for handling the event.

Note: in this context, libraries are sorted by their LoadOrder. Lower values will respond to the event earlier than higher values.

Virtual Machine

Ergo runs on a high-level virtual machine that executes Ops -- C# delegates of the form void Op(ErgoVM vm).

The VM implements Prolog semantics, though it is not an implementation of the Warren Abstract Machine. It can run in Batch and in Interactive mode: the former will compute all solutions before returning, while the latter wraps the core loop in an enumerator and yields solutions one at a time as they are generated.

Regardless, all you need to get a VM running is a knowledge base. At that point, you can do:

// --- Initialize VM ---
var vm = new ErgoVM(kb, VMFlags.Standard, DecimalType.CliDecimal);
var query = kb.InterpreterScope.Parse<Query>(queryString)
    .GetOrThrow(new InvalidOperationException());
vm.Query = vm.CompileQuery(query);

// --- Run in Batch mode --- 
vm.Run();
// At this point, all solutions have been computed and the query has finished executing.
foreach(var sol in vm.Solutions)
{
   // ...
}

// --- Run in Interactive mode --- 
foreach(var sol in vm.RunInteractive())
{
   // At this point, only the first solution has been computed.
   // Upon yielding, the vm will backtrack and try the next choice point.
}

Compilation

Compilation of predicates and queries is handled by the Compiler library, which responds to the KnowledgeBaseCreated and QuerySubmitted events.

Compilation is divided in roughly three steps:

  1. Dependency analysis: a DependencyGraph is built from the knowledge base, identifying cyclical terms and builtin calls
  2. Execution planning: the query (or predicate body) is converted into an ExecutionGraph where each node maps to a control flow operation, a builtin call, or a cyclical call.
    1. Static analysis is then performed on the graph, which is optimized and transformed down to the nodes that actually contribute to the solution.
  3. Op Compilation: the optimized graph is finally compiled (and cached) into an Op that targets the VM.

Hooks

Hooks are convenient wrappers for all the logic that is required to run an Ergo query against a VM in C#. At their simplest, a hook binds to a user-defined predicate in a specified module and compiles down to an Op. This Op can then be set as the VM's query and executed. Hooks handle this very case through the methods Call and CallInteractive which correspond 1:1 to Run and RunInteractive.

The Hook class provides two powerful static methods: MarshallDelegate and MarshallEvent. The former is used to combine any delegate with a hook, such that the hook is called automatically when calling the combined delegate. The latter is similar, but it calls the hook whenever an event is raised by temporarily adding the hook to the event's invocation list.

Shell

The shell is the standard interactive terminal Ergo environment. It provides functionality through Shell Commands and handles I/O.

The REPL is asynchronous and works by passing around a ShellScope to the command being dispatched.

Commands

Commands are added at the Facade level or at the Shell level. They are identified by a regular expression and a priority integer.