nustack

Shapes

An addon for document-model data. Ships two inseparable things: a DSL (Shape + Slot) for declaring structured shapes, and the structured-Ref blueprints those shapes live on. The DSL exists to wire up the Refs -- you can't usefully have one without the other.

Why not core

Nu's Ref is atomic: one name, one fabric slot. Shapes is for when you have nested structured data and want typed paths into it -- navigation that knows the structure and catches "no such field" / "wrong index type" at composition time. That's a DX layer on top of Refs, not part of the core algebra.

What the DSL gives you

  • Shape class + Slot descriptors -- declare a document shape as a Python class.
  • From a Shape definition, ShapeRef is derived automatically. Its .attr / [k] / [i] navigation produces correctly-typed child Refs into the same fabric.
  • Composition is recursive: a Shape whose Slot holds another Shape chains navigation depth-first.

Slot declaration surface

Annotation is authoritative. The annotation IS the Ref class (bare or parametric); ShapeMeta synthesizes a Slot at class-definition time and stamps a recursive TypeInfo onto the created Ref's payload. .slot(...) stays around for config (view=, size=, capacity=, ...) or as a legacy positional path.

class Profile(nu.Shape):
    # leaf refs: annotation only, no .slot()
    name:  nm.StrRef
    age:   nm.IntRef

    # nested single shape: annotation is the Shape (for dot-nav
    # autocomplete over its slots). Requires explicit .slot() because
    # bare Shape can't name the fabric.
    address: Address = nm.ShapeRef.slot(Address)

    # primitive-blob collections: raw python primitive inside
    tags: nv.PrimitiveListRef[str]
    meta: nv.PrimitiveDictRef[str, int]

    # decomposed shape collections: each element is its own ShapeRef
    orders:  nm.ShapesDictRef[int, Order]
    history: nm.ShapesListRef[Event]

    # with config: annotation + .slot(config)
    top10: nv.Kh57Ref[Order] = nv.Kh57Ref.slot(sample_size=10)

Annotation rules

Three shapes an annotation can take:

  1. Bare Ref class (nm.StrRef, nd.HeadingRef) - metaclass creates Slot(<cls>).
  2. Parametric Ref class (nv.PrimitiveListRef[str], nm.ShapesDictRef[int, Order]) - metaclass creates Slot(<origin>, **derived_kwargs) where kwargs come from the origin's _slot_kwargs_from_type_args classmethod.
  3. Bare Shape subclass (Order) - shorthand for a ShapeRef navigating that shape. Requires explicit = <fabric>.ShapeRef.slot(Order) because the annotation alone can't name the fabric. Gives dot-nav autocomplete over Order's own slots.

Type args discipline (level-2 typing)

Raw python primitives (str, int, bytes, ...) appear ONLY as type args of Primitive*Ref (whole-container blob storage). Decomposed containers (ListRef, DictRef, Kh57Ref, ...) take Ref classes or Shape subclasses as type args - each element is its own addressable ref.

# primitive: raw python type inside
tags: nv.PrimitiveListRef[str]

# decomposed: refs / shapes inside
orders:   nm.ShapesDictRef[int, Order]
queue:    nv.ListRef[Order]
queuerel: nv.ListRef[nm.DictRef[str, Order]]   # chained decomposition

TypeInfo

The recursive carrier lives at nu.lang.typeinfo.TypeInfo(py_type, key, elem). It's what gets stamped on the ref payload and read by every wrapper. py_type legitimately carries a Ref subclass, a Shape subclass, a Python primitive, or Any (the honest fallback for non-trivial unions and unresolvable forward refs). Accessors: is_ref / is_shape / is_primitive / is_any. to_form(tier) dispatches primitive types to their Form class (single source of truth for the type -> Form map).

T | None collapses to T. Non-trivial unions collapse to Any. Forward refs resolve lazily via typing.get_type_hints (memoized).

Structured-Ref families

Seven families, three tiers each. The blueprint a fabric exposes depends on what the backend supports.

FamilyBase (read)Mutable (write)Reactive (write + observe)
ItemItemRefMutableItemRefReactiveItemRef
MappingMappingRefMutableMappingRefReactiveMappingRef
SequenceSequenceRefMutableSequenceRefReactiveSequenceRef
SetSetRefMutableSetRefReactiveSetRef
ShapeShapeRefMutableShapeRefReactiveShapeRef
ShapesMappingShapesMappingRefMutableShapesMappingRefReactiveShapesMappingRef
ShapesSequenceShapesSequenceRefMutableShapesSequenceRefReactiveShapesSequenceRef

7 families × 3 tiers = 21 Ref blueprints.

Tier surface

TierMethods
Baseexists, missing, extract; Form surface: iter, len, contains, [k]/[i]/.attr
Mutableadds store, erase, init
Reactiveadds on_change, on_child_change, on_children_change, on_descendants_change

Form composition

Each Ref tier composes with the matching Form tier:

class MappingRef(MappingForm, StructuredRef): ...
class MutableMappingRef(MutableMappingForm, MappingRef): ...
class ReactiveMappingRef(ReactiveMappingForm, MutableMappingRef): ...

StructuredRef is the hierarchical base every family extends - the shared navigation toolkit. It is public: fabrics implement their Refs against it.

Form mixins live in nu2/forms/ (collections/abc + abc). Refs in nu2/domains/shape/refs/. Repo root for this domain: nu2/domains/shape/.

The on_change* family in the Reactive tier is not shape-specific - those queries live in nu2/forms/reactive.py (generic Form-layer infra). Reactive Refs pick them up by composing with ReactiveForm tiers from nu2/forms/.

Fabric examples

  • nu/mem -- in-memory writes, no native events -- exposes Mutable* blueprints.
  • nu/virtuals -- writes + change events -- exposes Reactive* blueprints.
  • read-only snapshot -- no writes, no events -- exposes base blueprints.

Address structure

A hierarchical Ref holds its parent in the tree, not off to the side:

  • children[0] = the parent Ref, marked structural - address structure, resolved but never value-read.
  • children[1:] = the keys - ordinary value slots, evaluated and read.

You write a path outside-in (user.profile.email); the tree holds it inside-out - email on top, user deepest in the children[0] chain. A Ref used as a key (tokens[current]) sits in a value slot, so it reads normally; its own parent stays structural. The role is a property of the (node, slot) edge, so the same Ref is never both structural and value. A flat Ref (one address, no hierarchy) declares structural = frozenset() and keeps its shape - no parent slot, no sentinel.

Because the parent lives in children, every tree pass reaches the whole Ref uniformly. No off-tree parent link, no with_children copy tax, no separate walk.

Substrate protocol

A Ref blueprint is abstract until wired to a fabric. The contract is a named Protocol - non-underscore names, pinned signatures - split in two.

Core - every Ref, every fabric. One method:

  • resolve_address(rt, nid) / aresolve_address - resolve the address by recursing children[0] (structural) and evaluating children[1:] (value).

Capabilities - declared per fabric, dispatched at compile. The verbs a fabric supports:

FabricCapabilities
mem, virtualsload/aload, write/awrite, erase/aerase, materialize/amaterialize
nudlestore, append, changed - async-only
iowrite, readline
ctx (FabricRef)read-only; the read self-yields the bound fabric. Calls are separate method atoms via MethodFactory.

load is the dual-role value fetch; materialize is the write-path fetch-or-create of the parent container (vivification). A fabric missing a capability fails at compile, not with a runtime error.

Value boundary. One symmetric pair on the Ref's value type:

  • lift - raw storage value to typed value (DateRef str to date). Runs on every read path.
  • lower - typed value to storage form. store/write takes one canonical typed input.

Vocab

  • Shape is canonical. schema is banned (DB connotation; see vocab.md).
  • Slot is canonical. field is banned.
  • ShapeRef is the derived Ref a Shape produces.
  • StructuredRef is the public hierarchical Ref base every family extends.

On this page