Rete.Bucket (Rete v0.5.0)

Copy Markdown View Source

An ordered multiset: add, remove one occurrence, and read back in arrival order.

Internal. Everything above it sees a list in arrival order, which to_list/1 produces. Adding and removing one occurrence are both O(1) amortized, however large the bucket grows โ€” a plain list cannot do both, and neither can a queue on its own.

Two things in the engine need exactly this, which is why it is one module rather than two. Rete.Memory keys a bucket per join key: a Rete.Network.Node.RootJoin has nothing to join on, so it stores every matching fact under one key. Rete.Agenda keys a bucket per sort key, which is one rule's pending matches, and cancels from it by value when truth maintenance takes a match back.

:queue holds every item ever pushed. :counts holds live occurrences per value, and :dead holds retracted ones still in the queue. Removal tombstones an occurrence, instead of rebuilding. to_list/1 and pop/1 then skip the first dead[value] occurrences of each value, in arrival order โ€” so the oldest occurrence is the one that went. Tombstones are compacted once they outnumber the living occurrences. See docs/design/engine.md ยง7.

:counts is what makes take/2 O(1), and take/2 is the only thing that reads it. So it is not maintained until the first take/2 builds it from the queue in one pass. A session that only inserts never takes from a bucket, and pays nothing for an index it never uses.

That build is safe because take/2 is also the only writer of :dead. While :indexed? is false there are no tombstones, so the queue holds exactly the live items.

iex> alias Rete.Bucket
iex> {:ok, bucket} = Bucket.new([:a, :b, :a]) |> Bucket.take(:a)
iex> Bucket.to_list(bucket)
[:b, :a]
iex> {:error, _bucket} = Bucket.take(bucket, :never_stored)
iex> {:ok, :b, _rest} = Bucket.pop(bucket)

Summary

Functions

Whether anything live is left.

A bucket holding items, in arrival order.

Takes the oldest live item, or :empty.

Adds items behind the ones already there. O(1) per item.

Adds one item behind the ones already there.

How many live items there are. O(1).

Removes the oldest live occurrence of target.

The live items, in arrival order.

Types

t()

@type t() :: %Rete.Bucket{
  counts: %{required(term()) => pos_integer()},
  dead: %{required(term()) => pos_integer()},
  dead_total: non_neg_integer(),
  indexed?: boolean(),
  live: non_neg_integer(),
  queue: :queue.queue(term())
}

Functions

empty?(bucket)

@spec empty?(t()) :: boolean()

Whether anything live is left.

new(items \\ [])

@spec new([term()]) :: t()

A bucket holding items, in arrival order.

pop(bucket)

@spec pop(t()) :: {:ok, term(), t()} | :empty

Takes the oldest live item, or :empty.

Discards the tombstones it walks past, so the cost of skipping one is paid once.

push(bucket, items)

@spec push(t(), [term()]) :: t()

Adds items behind the ones already there. O(1) per item.

push_one(bucket, item)

@spec push_one(t(), term()) :: t()

Adds one item behind the ones already there.

The single-item clause of push/2 delegates here, because one at a time is the common call. The list form walks its argument three times and takes its length. This does neither.

size(bucket)

@spec size(t()) :: non_neg_integer()

How many live items there are. O(1).

take(bucket, target)

@spec take(t(), term()) :: {:ok, t()} | {:error, t()}

Removes the oldest live occurrence of target.

Returns {:ok, bucket}, or {:error, bucket} if there is no such occurrence. A miss carries a bucket too, because the miss is what builds the index. Rete.Agenda misses once per activation that has already fired, so discarding it would rebuild every time.

A miss is reported rather than silently doing nothing. A caller that propagated a retraction of something the bucket never held would corrupt every count below it, and Rete.Agenda tells a cancelled activation from an already-fired one by exactly this.

to_list(bucket)

@spec to_list(t()) :: [term()]

The live items, in arrival order.