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 six variants is present.
    pub steps:     Option<Vec<Step>>,
    pub rules:     Option<Vec<Rule>>,
    pub compose:   Option<ComposeSpec>,
    pub auto:      Option<AutoSpec>,
    pub from_diff: Option<FromDiffSpec>,
    pub symmetric: Option<SymmetricSpec>,

    // Modifier: oriented rewrites appended to the compiled chain.
    pub directed_equations: Option<Vec<DirectedEquationSpec>>,

    // 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, symmetric, auto, or from_diff. A directed_equations modifier may accompany a body, appending oriented rewrites to the compiled chain.

Compilation has two entry points. compile is schema-independent: it handles the schema-parametric bodies (steps, rules, compose, symmetric) and rejects auto and from_diff, which need a concrete source and target schema, with LensDslError::AutoRequiresSchemas. compile_with_schemas handles those two as well, running the auto-generation engine for auto and diff_to_protolens for from_diff. It also verifies the compiled chain against the declared target: the chain is instantiated at the source schema and the NSID of its output schema is compared against target, yielding LensDslError::TargetMismatch on divergence. The schema-independent path cannot run that comparison, since it has no schema to instantiate against.

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 deterministic runtime checkers: each takes a lens and a given instance, derives the views and complements it needs from that instance ( perturbs a single leaf value; reuses one complement on both puts), and asserts the corresponding 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. The checks that back this are sampled, not exhaustive. The property tests in crates/panproto-lens/src/laws.rs run GetPut, PutGet, and PutPut over generated scenarios that span the identity and projection families, nested trees of depth three, lenses carrying vertex and edge remaps, and field-transform lenses, with the PutGet and PutPut views drawn from a view-mutation strategy rather than a single canned perturbation. The DSL side adds crates/panproto-lens-dsl/tests/step_laws.rs, which compiles a document per step constructor and runs the same runtime law checks on the instantiated chain. A coerce_sort step (and each directed_equations entry) is checked for honesty when the DSL compiles it: the declared coercion class’s round-trip laws are run against sampled inputs of the source kind, and a class that fails on those samples is refused with LensDslError::CoercionNotHonest. Each of these is evidence, not proof; the samples that were tried do not certify a law for inputs that were not.

The runtime checkers above remain deterministic smoke checks: they assert each law of a supplied lens on one instance, and they are not the sampling layer.

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