Mnemosyne follows a simple hub-and-spokes architecture. The central hub is an LSP server (called the "Argot Server") which integrates a number of synthesis modules (called "Muses") and the developer's IDE. The muses communicate using an extended form of LSP as documented below.
The Argot server extends existing programming languages with explicitly represented (i) existential quantifiers such as examples and tests, (ii) universal quantifiers such as contracts and types, and (iii) natural language documentation and prose. These new kinds accompany subtrees of a software program. We refer to these new kinds collectively as "Argot." Argot is intended to provide a medium of exchange between automated program synthesis and development tools and IDEs (termed "Muses").
This hub communicates with the developer and with synthesis modules or Muses using a superset of LSP, which includes the typical LSP actions along with actions for:
Interactions should work on a framework-by-framework basis. For each supported language there are many different test frameworks, type inference frameworks (e.g. Mypy vs. Reticulated for Python, or TypeScript vs. Flow for JavaScript), and different documentation generators, with their different input conventions (see this long list of Python documentation tools). Typically there will be one Muse per framework, responsible for watching for opportunities to contribute or to act on the contributions of other muses.
Argot adds a new interface (ArgotShowPromptParams
) and a new endpoint (argot/window/showPrompt
) for reading input from the user.
argot/window/showPrompt
is exactly the same as window/showMessageRequest
, except that instead of the user being restricted to picking from a fixed set of inputs, the user is also allowed to simply enter a string.
export interface ArgotShowPromptParams extends ShowMessageRequestParams {
/**
* A default value for the user to edit.
*/
defaultValue?: string;
/**
* Whether a multi-line input widget is preferred.
*/
multiLine?: boolean;
}
Argot adds two concepts to LSP: Annotations and Contributions. Annotations are attached to regions in files (or entire files), and each Annotation has a list of Contributions.
Annotations are created with LSP ranges, but their start and end points are updated as the buffer changes. (Annotations without regions apply to the whole file.)
Contributions are tagged with a kind (code, type, test, or prose) and the name of the muse that contributed them. A Contribution has a data slot that contains a table of properties.
Annotations are created by muses, but they have no provenance. They belong to the document. Contributions, on the other hand, always belong to the muse that contributed them. Two muses can make separate Contributions to the same Annotation, but their Contributions always remain distinct.
At a very high level this approach could be thought of as being analogous to a "tuple space", where the tuples are addressed by (file, range, kind, muse, key) -- bearing in mind that the range is updated as the file is edited. Muses are able to contribute what they know when they know it, and can both look up what has already been contributed and be notified as other muses contribute.
Currently this involves a minimal extension to LSP: a GetAnnotations
request, to get the annotations for a file or a region in a file, and
a didAnnotate
notification, to add new annotations.
As a file changes, annotations move around. If an annotation applies
to a region in a file that has been deleted, that annotation is also
deleted. The GetAnnotations
request only returns annotations that
fall entirely within the requested range, not annotations that only
overlap it at one end.
There are two new endpoints:
DidAnnotate notification
Notification:
argot/didAnnotate
didAnnotateParams
defined as follows:export interface DidAnnotateParams {
annotations: Annotation[];
}
GetAnnotations request
Request:
argot/getAnnotations
getAnnotationsParams
defined as follows:export interface GetAnnotationsParams {
textDocument: TextDocumentIdentifier;
/**
* No range means to get the annotations for the whole file.
*/
range?: Range;
}
Response:
Annotation[]
. Only the annotations that fall
inside the specified range are returned.Annotation and Contribution are defined as follows:
export namespace ArgotKind {
export const Code = 'code';
export const Types = 'types';
export const Tests = 'tests';
export const Prose = 'prose';
}
export interface Annotation {
/**
* The file to annotate.
*/
textDocument: TextDocumentIdentifier;
/**
* The range the annotation applies to.
* An annotation without a range applies to the whole file.
*/
range?: Range;
contributions: Contribution[];
}
export interface Contribution {
/**
* The muse responsible for this contribution.
*/
source: string;
/**
* An optional sub-key for the contribution.
* Within an Annotation, Contributions are addressed by source and key.
* If there is no key, there can only be one Contribution from a given
* muse per Annotation.
*/
key?: number | string;
kind: ArgotKind;
/**
* A dictionary of properties.
*/
data: any;
}
While LSP does define interfaces with member types that are
disjunctions of interface, this is a feature that is not widely
supported by LSP clients. Accordingly the data
member of a
Contribution is only defined as a dictionary. The following types,
then, are provided not as requirements, but as guidance for how to
format data in a way other LSP clients can understand.
export interface Data {}
export interface InferredType extends Data {
name: string;
}
export interface TypeCandidates extends Data {
candidates: TypeCandidate[];
}
export interface TypeCandidate {
name: string;
probability: number;
}
export namespace TestCaseKind {
export const UnitTest = 1;
export const BinaryTest = 2;
}
export interface UnitTestCaseResult {
expected: string | number | boolean;
}
export interface BinaryTestCaseResult {
stdout?: string;
stderr?: string;
/**
* A non-negative integer representing an exit code.
*/
exitCode: number;
}
export interface TestCase extends Data {
name: string;
target: string | Location;
testCaseKind: TestCaseKind;
arguments: (string | number)[];
expected: UnitTestCaseResult | BinaryTestCaseResult;
}
export interface TestSuite extends Data {
name: string;
cases: TestCase[];
}
See Muses for a list of the specific synthesis techniques currently integrated into Mnemosyne.
From Argot Server’s perspective, each Muse is an LSP server in its own right, which uses LSP's existing capability discovery interface to indicate the operations it supports. This provides uniformity for Argot Server at the hub of Mnemosyne’s design. This allows a Muse to, say, offer capabilities for type extraction, or test runs, individually. Whenever possible, these endpoints should adopt existing LSP definitions (e.g., Locations).
Using base LSP along with these extensions, LSP provides sufficient primitives to cover all communication needs between Argot Server and the Muses, including:
Argot Server is more than a router: it also understands how to combine results from multiple Muses. This allows preexisting LSP servers to be used as Muses, guaranteeing a baseline experience for supported languages that other Muses may augment. This allows developers to migrate from an existing language server to Argot as a purely additive experience: everything they are already used to, plus what Mnemosyne can do.
For technical information on how to write a new Muse and integrate it with Mnemosyne's argot-server see argot-server/CONTRIBUTING.md#how-to-write-a-muse.
To ease the burden on Muse authors, we plan (eventually) to implement shims in each Mnemosyne-supported language which will handle the LSP communication. These shims will define a series of abstract functions (or methods or similar) that correspond to Mnemosyne's LSP superset, allowing Muses to overwrite them to register new actions as simple functionality. The status of our shim development is listed in the following table:
Interface | Status |
---|---|
Python | In development for the Trinity Muse using pygls |
Command Line | Planned, development should start soon |
Furthermore, because Muses use LSP as their uniform interface, it is possible to define Muses compositionally, as pipelines of simpler Muses working in stages. Muses may be instantiated from other full-fledged Muses, or from simple Muses purpose-built for composition (which may be referred to as “middleware”).
mypy
muse determining it can't work over JavaScript, or the unittest
testing library can't work over pytest
definitions.)
muse-lang
generic function.Views are responsible for non-synthesis management of software information, including collecting and writing code, types, tests, and prose from and to the software project. They provide baseline capabilities for the LSP superset for use by the IDE and downstream Muses. For example, a test View will register with Argot Server and provide endpoints to look up the available test cases and write new ones. When a downstream Muse generates new test cases, it may use this View Muse's registered test writing capabilities to add them to the project in a general way. In this way multiple downstream Muses can leverage the test wrangling functionality provided by a View Muse. This approach has three main advantages:
Additionally, we are developing bi-directional translation between our supported base programming languages (Python, JavaScript and Common Lisp) and the DSLs often used by program synthesis tools. These translation defining facilities will allow Muses to operate over simplified DSLs tailored to their synthesis technique and target domain, while ensuring that the synthesis results may be inserted into real-world programs written in the full base programming languages.
We are building a library, Argot, providing primitives based on Lenses which may be used to easily define new bi-directional translations between DSLs and full base languages. A draft paper describing our methodology is available at argot-core-trinity/main.tex.