Skip to content

Queries: traversal, sequences, generators, and bound sources

Status: proposed design; not implemented. Nothing here is yet part of the grammar recorded in expressions.md. The design depends on function types (issue 0009) and replaces the special cards keyword (issue 0008) with an ordinary bound value.

A query does not have one privileged source type. A pipeline receives a value, and each step states the weakest property it needs from that value.

For example:

cards
| filter(.metadata.type == "concept")
| take(5)

filter only needs traversal. take additionally needs a defined order. size instead needs finiteness.

These properties should be represented directly in the type hierarchy.

Collection hierarchy

There are three independent questions about a group of values:

Does it support traversal?

Is that traversal ordered?

Is the number of elements finite?

The hierarchy follows directly from those questions:

abstract type Iterable<T>

abstract type Collection<T> : Iterable<T>

abstract type Stream<T> : Iterable<T>

abstract type Seq<T> : {Collection<T>, Stream<T>}

type List<T> : Seq<T>

type Set<T> : Collection<T>

Conceptually:

                    Iterable<T>
                    /         \
                   /           \
          Collection<T>      Stream<T>
             /     \           /
            /       \         /
       Set<T>       Seq<T>
                      |
                   List<T>

Iterable<T> means only that the values can be traversed. It promises neither finiteness nor a meaningful order.

Collection<T> adds finiteness. Its elements can therefore be counted, membership can be decided by complete traversal, and multiplicity has a finite sum.

Stream<T> adds a stable traversal order. It does not promise that traversal terminates.

Seq<T> has both properties: it is a finite collection with a defined order.

List<T> is a materialized sequence.

Set<T> is a finite collection without positional semantics.

This makes Seq<T> the intersection that was missing from the previous hierarchy:

Seq<T> : {Collection<T>, Stream<T>}

It is not necessary to choose whether Seq is fundamentally a collection or fundamentally a stream. It is both.

The contracts are semantic

These types describe observable guarantees, not storage strategies.

A List<T> is materialized.

A Seq<T> may be materialized or generated lazily.

A Stream<T> may be finite or unbounded.

Consequently, laziness is not a type relationship.

A lazy sequence remains:

Seq<T>

because it still has a finite size and a defined order.

An unbounded generator is:

Stream<T>

because it has an order but cannot satisfy the finite Collection<T> contract.

This is the reason Stream<T> exists. It is not "the lazy version of Seq<T>". It is the ordered traversal type without the promise of finiteness.

Collection laws

Collection<T> keeps the deliberately weak contract already established for collections:

size     : Collection<T> -> Int
contains : (Collection<T>, T) -> Bool
count    : (Collection<T>, T) -> Int

with:

size(c) is finite

contains(c, x) <=> count(c, x) > 0

size(c) = Σ count(c, x)

A collection contains occurrences, not necessarily unique values. That allows both Seq<T> and Set<T> to refine it.

Set<T> adds the constraint:

count(s, x) <= 1

Seq<T> instead adds stable order and positional semantics.

Neither property belongs on Collection<T> itself.

Order belongs to Stream<T>

A Stream<T> establishes a stable succession of elements:

x₀, x₁, x₂, ...

It does not establish a finite size.

That is exactly what operations such as first, take, and drop require. They do not require a collection; they require an ordered traversal.

For example:

fn first<T>(xs: Stream<T>) : Option<T>

fn take<T>(xs: Stream<T>, n: Int) : Seq<T>

fn drop<T>(xs: Stream<T>, n: Int) : Stream<T>

take returns a Seq<T> because a bounded prefix is finite even when the input is not.

drop cannot make the same promise. Dropping five elements from an unbounded stream still produces an unbounded stream.

When the stronger input type is known, operations may preserve it:

fn drop<T>(xs: Seq<T>, n: Int) : Seq<T>

The same rule applies to transformations:

fn map<T, U>(xs: Stream<T>, f: T -> U) : Stream<U>
fn map<T, U>(xs: Seq<T>,    f: T -> U) : Seq<U>

fn filter<T>(xs: Stream<T>, f: T -> Bool) : Stream<T>
fn filter<T>(xs: Seq<T>,    f: T -> Bool) : Seq<T>

A transformation should preserve every guarantee it can prove.

Finiteness belongs to Collection<T>

Operations that require complete traversal accept Collection<T>, not Stream<T>:

fn size<T>(xs: Collection<T>) : Int
fn count<T>(xs: Collection<T>, x: T) : Int

Likewise, global ordering requires a finite input:

fn sort<T: Orderable>(xs: Collection<T>) : Seq<T>

This gives an unordered finite collection a deterministic order:

names : Set<String>

ordered = names | sort

By contrast:

stream | sort

is a type error, because sorting an arbitrary stream would require seeing an end that may not exist.

Materialization has the same boundary:

fn list<T>(xs: Seq<T>) : List<T>

An arbitrary Stream<T> cannot be materialized safely without first introducing a finite bound:

stream
| take(100)
| list

Iterable<T> is the common floor

Iterable<T> exists so that unordered and potentially unbounded traversal are not artificially forced into either Collection or Stream.

It is intentionally weak.

It does not provide:

size
position
first
take
drop
indexing
sorting

Those operations require stronger types.

An Iterable<T> may still participate in operations whose semantics do not depend on order or finiteness, but a function cannot expose an arbitrary traversal order as a positional result without first establishing an order.

This is particularly important for Set<T>. Its runtime representation obviously has to enumerate elements somehow, but that enumeration order is not part of the HQL value.

A positional operation therefore cannot consume a bare set:

xs : Set<Card>

xs | take(5)       // error: Set<Card> is not Stream<Card>

An order must first be introduced:

xs
| sort(by = .name)
| take(5)

Now the prefix is reproducible.

Map<K,V> is separate

Map<K,V> should not be forced into the one-parameter hierarchy merely to make the diagram complete.

Doing so immediately raises the question of what its element type is:

K?
V?
(K, V)?
Entry<K,V>?

Until HQL has an explicit entry or pair abstraction, Map<K,V> is better treated as a keyed collection with its own operations.

This avoids inventing a false subtype relationship just for structural symmetry.

cards is a Seq<Card>

The vault is finite, and card enumeration must be reproducible. Therefore the standard cards value should be:

cards : Seq<Card>

not:

Set<Card>

and not:

Stream<Card>

The sequence order must be part of the source contract. For the vault, ordering by canonical card name is sufficient and already aligns with the Orderable property used elsewhere.

This makes:

cards | take(5)

deterministic across machines.

Filesystem discovery order must never become observable HQL semantics.

yield defines an iterating function

A function whose body contains yield is an iterating function.

For example:

fn get_all_cards(v: Vault) : Seq<Card> {
    for c in v.documents {
        yield c
    }
}

Each yield contributes one element to the traversal in the order it is reached.

Calling the function does not imply materialization. Evaluation advances only as far as its consumer demands:

get_all_cards(vault)
| take(5)

need only produce five cards.

The important point is that yield does not decide whether the result is a Seq<T> or a Stream<T>.

The declared return type states the guarantee:

fn finite_source(...) : Seq<T> {
    ...
    yield x
}

fn unbounded_source(...) : Stream<T> {
    ...
    yield x
}

A Seq<T> declaration promises that traversal is finite.

A Stream<T> declaration does not.

The checker verifies that yielded values have type T; the surrounding source or implementation must justify the stronger finiteness contract when Seq<T> is declared.

This keeps generator syntax separate from collection semantics.

List<T> and generated Seq<T> are deliberately different

These are not equivalent:

fn a(...) : List<T>
fn b(...) : Seq<T>

Returning a List<T> promises a materialized value.

Returning a Seq<T> promises only a finite ordered sequence. Its implementation may be lazy.

That gives HQL a useful abstraction boundary:

List<T>   finite + ordered + materialized
Seq<T>    finite + ordered
Stream<T> ordered

Materialization becomes an implementation property only where the public type explicitly promises it.

Runtime representation is not Stream<T>

The runtime carrier should not itself be called Stream.

A lazy Seq<T> and a Stream<T> need exactly the same underlying mechanism. Naming the carrier Stream conflates the implementation strategy with the HQL semantic type.

Use a neutral representation instead, conceptually:

Traversal(Rc<TraversalSource>, TypeRef)

where TraversalSource describes how to start a fresh traversal and TypeRef says whether the HQL value is:

Seq<T>
Stream<T>
...

The carrier is a factory, not a cursor.

Cloning a value therefore clones the traversal factory, not its current position.

This keeps:

first_two = cards | take(2)
also_two  = cards | take(2)

referentially transparent. Both traversals begin from the same source state and produce the same values.

A live shared cursor would make the second expression depend on whether the first expression had already consumed the value, which does not fit HQL's pure value model.

Traversal is pushed

The existing evaluator is tree-walking and cannot naturally suspend its Rust call stack at every yield.

The simplest lazy implementation therefore uses push traversal.

Conceptually:

TraversalSource -> Sink<T> -> Continue | Stop

yield sends a value to the supplied sink.

The sink can stop traversal early, which is enough for:

stream | take(5)

even when stream is unbounded.

Lazy pipeline steps compose by wrapping sinks.

map transforms a value before forwarding it.

filter decides whether to forward it.

take forwards at most n values and then returns Stop.

Materialization is simply a sink that collects values into a Vec<Value>.

This replaces the current implicit forcing through elements.

Pull is a separate feature

A pushed traversal does not need next.

If HQL eventually adds pure pull traversal, the natural operation is:

fn next<T>(xs: Stream<T>) : Option<(T, Stream<T>)>

The returned tail is a new stream value; nothing mutates.

That is fundamentally different from Java or Rust iterators, where next advances mutable cursor state.

HQL therefore does not need separate public Iterable<T> and Iterator<T> objects in the Java sense. Iterable<T> in the hierarchy describes the capability to traverse; Stream<T> describes an ordered traversal value. Neither is a mutable cursor.

The remaining obstacle is that HQL currently has no tuple type suitable for:

(T, Stream<T>)

and true pull over an unbounded generator would require evaluator suspension.

For those reasons, next should remain outside the initial generator design.

Push traversal is sufficient for query pipelines.

vault is an ordinary bound value

The runtime exposes the current vault as:

vault : Vault

It is not query syntax and not a special table.

A program running without a vault simply has no vault binding.

A source function can consume it like any other value:

fn get_all_cards(v: Vault) : Seq<Card> {
    ...
}

and the standard environment can define:

cards = get_all_cards(vault)

Because Seq<T> may be lazy, this assignment does not require eager enumeration.

cards is therefore just an ordinary value with type:

Seq<Card>

Partial application is not part of the source model

The previous design used:

cards = bind(get_all_cards, vault)

to explain deferred execution.

That is unnecessary.

If:

get_all_cards(vault)

returns a lazy Seq<Card>, then:

cards = get_all_cards(vault)

already has the required behaviour.

Partial application is still useful independently:

cards
| sort(by = partial(distance_to, here))

or whatever syntax the function design eventually chooses.

But that is a function-language question, not a query-source question.

It should therefore be resolved together with function types rather than making bind part of the sequence model. This also avoids the existing collision with "binding" generic type parameters.

Resulting model

The final distinction is small:

Iterable<T>
    can be traversed

Collection<T>
    Iterable<T>
    finite
    size and multiplicity are defined

Stream<T>
    Iterable<T>
    stable order
    may be unbounded

Seq<T>
    Collection<T> + Stream<T>
    finite and ordered

List<T>
    Seq<T>
    materialized

Set<T>
    Collection<T>
    unique values
    no positional order

The runtime adds one implementation concept:

TraversalSource
    restartable lazy producer
    usable by either Seq<T> or Stream<T>

and yield adds one function form:

iterating function
    produces values through a TraversalSource
    return type declares whether the result is Seq<T> or Stream<T>

This separates the concerns cleanly:

Iterable    traversal
Collection  finiteness
Stream      order
Seq         finiteness + order
List        materialization
Set         uniqueness
yield       production
Traversal   runtime laziness

No one type has to stand for two of those ideas.

Consequences

cards becomes an ordinary Seq<Card> bound to the current vault.

take, drop, and first operate on Stream<T> rather than requiring Seq<T>.

size and count operate on Collection<T> rather than Stream<T>.

sort converts a finite collection into a sequence.

take converts an arbitrary ordered stream into a finite sequence.

A generated finite sequence and a materialized list share the same sequence semantics without pretending to have the same representation.

An unbounded generator fits naturally without weakening the laws of Collection<T>.

The runtime gets one lazy traversal carrier rather than separate machinery for lazy sequences and streams.

Most importantly, query pipelines no longer start from one privileged type. Each operation declares the property it actually needs, and the type hierarchy expresses exactly those properties.