Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Lens DSL: denotational semantics

In plain terms

A lens DSL spec is a recipe for building a bidirectional transform between two schemas. You declare which fields map to which (with optional value-level expressions), and the compiler produces a triple of functions that satisfies three round-trip laws by construction. This page pins down what the spec compiles to and what “satisfies the laws” means.

Surface syntax

The Nickel surface (canonical authoring form). JSON and YAML surfaces are isomorphic via serde.

{
  id = "user.v3-to-v4",
  description = "Rename `name` and replace `age` with `years`",
  source = "dev.example.user.v3",
  target = "dev.example.user.v4",
  steps = [
    { rename_field = { old = "name", new = "display_name" } },
    { remove_field = "age" },
    { add_field = { name = "years", kind = "integer", default = 0, expr = "old.age" } },
  ],
}

Each step is a single-key object whose key selects the variant. The full step grammar is in crates/panproto-lens-dsl/src/document.rs.

Abstract syntax

pub struct LensDocument {
    pub id: String,
    pub description: String,
    pub source: String,
    pub target: String,

    // Body: exactly one of the four variants is present.
    pub steps:   Option<Vec<Step>>,
    pub rules:   Option<Vec<Rule>>,
    pub compose: Option<ComposeSpec>,
    pub auto:    Option<AutoSpec>,

    // Rule-variant metadata.
    pub passthrough: Option<Passthrough>,
    pub invertible:  Option<bool>,

    // Protocol-specific extension metadata.
    pub extensions: HashMap<String, serde_json::Value>,
}

pub enum Step {
    // High-level field combinators
    RemoveField { remove_field: String },
    RenameField { rename_field: RenameSpec },
    AddField    { add_field: AddFieldSpec },

    // Value-level transforms
    ApplyExpr    { apply_expr: ApplyExprSpec },
    ComputeField { compute_field: ComputeFieldSpec },

    // Structural combinators
    HoistField { hoist_field: HoistSpec },
    NestField  { nest_field: NestSpec },
    Scoped     { scoped: ScopedSpec },
    Pullback   { pullback: PullbackSpec },

    // Sort-level coercions and merges
    CoerceSort { coerce_sort: CoerceSortSpec },
    MergeSorts { merge_sorts: MergeSortsSpec },

    // Elementary theory operations
    AddSort      { add_sort: AddSortSpec },
    DropSort     { drop_sort: String },
    RenameSort   { rename_sort: RenameSpec },
    AddOp        { add_op: AddOpSpec },
    DropOp       { drop_op: String },
    RenameOp     { rename_op: RenameSpec },
    AddEquation  { add_equation: EquationSpec },
    DropEquation { drop_equation: String },
}

The top-level type is LensDocument, not LensSpec. The document carries source and target NSID fields naming the two schemas, and exactly one body variant (steps, rules, compose, or auto). The resolver loads the source schema and the compiler applies the body to it, then checks that the result matches the named target. See panproto_lens_dsl::compile.

Semantic domain

For schemas , and complement type , the set of lenses on is

with elements written as triples . The implementation in panproto-lens represents this triple by the Lens struct; the categorical model is the standard asymmetric-lens construction of Foster et al. (2007) with complements after Litt et al. (2020). The semantic function

takes a document and a source schema and returns a concrete lens. (The target schema and the complement type are determined by the document and the source.)

The three laws

A lens is lawful iff for all and :

panproto_lens::laws::check_get_put, check_put_get, and check_put_put are property-test runners that sample , , , from the schema’s value space and assert each equation.

Semantic equations

For each step constructor, is a function , where a is a schema-parameterised lens (see Protolens composition). The document-level semantics is the left-to-right composition of the per-step semantics, applied to the source schema:

where and . The composition operator is the sequential lens composition

For a representative subset of steps:

where is the trivial complement (a singleton) and on the right-hand side of is the expression-language semantics. The remaining 15 step constructors follow the same pattern; the implementation of each lives under crates/panproto-lens-dsl/src/steps.

Composition between adjacent step semantics is gated at construction time by protolens_composable: see Protolens composition. Compilation in the implementation is handled by panproto_lens_dsl::compile, which produces a CompiledLens carrying the ProtolensChain corresponding to along with the value-level FieldTransforms extracted from any steps.

Complement composition

Sequential composition of lenses requires composing their complements. Complement::compose is a partial commutative monoid:

  • Identity. The empty complement satisfies .
  • Commutativity. When defined, .
  • Associativity. When defined, .
  • Partiality: is defined iff:
    • and have the same source-schema fingerprint (otherwise ComplementFingerprintMismatch); and
    • For every key in both, (otherwise ComplementConflict with the offending key).

Pre-flight predicate: Complement::is_compatible(c1, c2).

The fingerprint is a 64-bit hash of the source schema (computed with the standard library’s DefaultHasher in panproto_lens::asymmetric::schema_fingerprint), so complements computed against syntactically distinct but structurally equal schemas are still compatible.

Soundness

The compilation function preserves lawfulness: if every step compiles to a lawful lens (which the combinator algebra guarantees), the composed result is lawful. Property tests in crates/panproto-lens/src/laws.rs verify each combinator against random inputs sampled from the schema’s value space.

What is intentionally not modelled

  • Lossy migrations as full lenses. A migration that drops information cannot satisfy GetPut. The DSL allows DropField, but the resulting object is a partial lens; the laws hold only on the surviving structure, and CI tests skip the GetPut law for steps annotated as lossy.
  • Time complexity of put. Some combinators have linear put cost in the size of the source; the semantics fixes the value, not the cost.
  • Equivalence of two distinct DSL specs that compile to the same lens. The DSL deliberately exposes step ordering even when steps commute; canonicalisation is left to the user.

See also