---
title: "A weighted graph-structured stack"
description: "A persistent compressed map from stacks to weights, extracted from glrmask."
pubDate: 2026-07-25
visibility: "public"
category: "library"
tags: ["rust", "python", "parsing", "data-structures", "constrained-decoding"]
homepage:
  featured: false
project:
  period: "2026"
  status: "Released · v0.2.2"
  repository: "https://github.com/IsaacBreen/weighted-gss"
  package: "https://crates.io/crates/weighted-gss"
---

A graph-structured stack compresses a set of stacks into a graph where each path represents one stack. When stacks share a run of items, their paths use the same nodes; the graph branches only where they differ.

`WeightedGSS` extends this representation by assigning a weight to each stack.

For example, suppose we construct one from three stacks:

```python
stacks = [
    ([0, 5, 8, 12, 18, 31], W_1),
    ([0, 5, 14, 27, 31], W_1),
    ([0, 4, 13, 15, 27, 31], W_2),
]

WeightedGSS.from_stacks(stacks)
```

<figure class="article-figure article-figure--wide article-figure--wgss-map" data-weighted-gss-article>
  <div class="article-wgss-graph-frame">
    <svg class="article-wgss-graph" data-weighted-gss-article-graph role="img" aria-label="The three stacks compressed into a weighted graph-structured stack, with the leaf on the left and root on the right."></svg>
  </div>
  <aside class="article-wgss-legend" aria-label="Node legend">
    <div class="article-wgss-legend-head">Legend</div>
    <div class="article-wgss-legend-rule"></div>
    <div class="article-wgss-legend-item">
      <svg viewBox="0 0 40 40" aria-hidden="true"><circle class="wgss-thumb-node-upper" cx="20" cy="20" r="16"></circle><circle class="wgss-thumb-node-upper-dot" cx="20" cy="20" r="4"></circle></svg>
      <span><strong>Upper</strong></span>
    </div>
    <div class="article-wgss-legend-item">
      <svg viewBox="0 0 40 40" aria-hidden="true"><rect class="wgss-thumb-node-iface" x="4" y="4" width="32" height="32"></rect><g class="wgss-thumb-iface-label" aria-label="W₁"><text x="20" y="20" text-anchor="middle" dominant-baseline="central" font-size="19" font-style="normal" font-weight="800" data-weight-component="base" data-weight-anchor-x="20" data-weight-anchor-y="20">W</text><text x="30" y="26" text-anchor="middle" dominant-baseline="central" font-size="9" font-style="normal" font-weight="800" data-weight-component="subscript" data-weight-anchor-x="30" data-weight-anchor-y="26">1</text></g></svg>
      <span><strong>Branch</strong></span>
    </div>
    <div class="article-wgss-legend-item">
      <svg viewBox="0 0 40 40" aria-hidden="true"><polygon class="wgss-thumb-node-lower" points="20,2 38,20 20,38 2,20"></polygon><circle class="wgss-thumb-node-lower-dot" cx="20" cy="20" r="4"></circle></svg>
      <span><strong>Lower</strong></span>
    </div>
  </aside>
  <figcaption>Hover or tap a stack or graph edge to trace it through the graph.<sup class="article-note-ref"><a href="#weighted-gss-note-1">1</a></sup></figcaption>
</figure>

The graph is split by an interface of weight-bearing nodes. Every non-empty path crosses this interface exactly once, so each represented stack has exactly one weight. Nodes on the root side of the interface are upper nodes; those on the leaf side are lower nodes.

To limit how much of the graph must be traversed to reach each weight, `WeightedGSS` keeps the interface close to the root. This matters because mask generation reads the weights frequently.

Each operation returns a new graph, creating only the nodes in the part that changed and reusing the rest. The unchanged subgraphs are immutable, so they can be shared safely between the old and new graphs.

Most of the time, however, the graph is largely linear. Ambiguity is usually local and regular, and often absent altogether. `WeightedGSS` therefore packs deterministic chains into segments and exposes a mutable `LinearPrefix` for callers that want a fast path for the linear case.<sup class="article-note-ref"><a href="#weighted-gss-note-2">2</a></sup>

## Why does a weight need join?

A `pop` can make two stacks identical, requiring their weights to be joined:

<figure class="article-figure article-figure--wide article-figure--wgss-join" data-weighted-gss-join-stages>
  <div class="article-wgss-join-stages">
    <div class="article-wgss-join-stage">
      <pre class="astro-code wgss-stack-code article-wgss-stage-code"><code><span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">WeightedGSS.from_stacks([</span></span><span class="line wgss-stack-line" data-path-index="0" data-match-path-indices="0,3" data-weight-contributor="p" tabindex="0" role="button" aria-label="Before pop: stack 0, 4, 9, 18 with weight W_1" aria-pressed="false"><span style="color:#24292E;--shiki-dark:#E1E4E8">    ([</span><span style="color:#005CC5;--shiki-dark:#79B8FF">0</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">4</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">9</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">18</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], W_1),</span></span>
<span class="line wgss-stack-line" data-path-index="1" data-match-path-indices="1,3" data-weight-contributor="q" tabindex="0" role="button" aria-label="Before pop: stack 0, 4, 9, 27 with weight W_2" aria-pressed="false"><span style="color:#24292E;--shiki-dark:#E1E4E8">    ([</span><span style="color:#005CC5;--shiki-dark:#79B8FF">0</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">4</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">9</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">27</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], W_2),</span></span>
<span class="line wgss-stack-line" data-path-index="2" data-match-path-indices="2" data-weight-contributor="r" tabindex="0" role="button" aria-label="Before pop: stack 0, 4, 10, 35 with weight W_3" aria-pressed="false"><span style="color:#24292E;--shiki-dark:#E1E4E8">    ([</span><span style="color:#005CC5;--shiki-dark:#79B8FF">0</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">4</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">10</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">35</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], W_3),</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">])</span></span></code></pre>
      <svg class="article-wgss-join article-wgss-interactive-graph" data-weighted-gss-join-before-graph role="img" aria-label="Three stacks share the suffix 0 and 4. Two continue through 9 to weights W_1 and W_2, while a third continues through 10 to weight W_3. The root is on the right."></svg>
    </div>
    <div class="article-wgss-join-operation" aria-hidden="true">
      <span>POP ↓</span>
    </div>
    <div class="article-wgss-join-stage">
      <pre class="astro-code wgss-stack-code article-wgss-stage-code"><code><span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">WeightedGSS.from_stacks([</span></span><span class="line wgss-stack-line" data-path-index="0" data-match-path-indices="0,3" data-weight-contributor="p" tabindex="0" role="button" aria-label="After pop: stack 0, 4, 9 with weight W_1" aria-pressed="false"><span style="color:#24292E;--shiki-dark:#E1E4E8">    ([</span><span style="color:#005CC5;--shiki-dark:#79B8FF">0</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">4</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">9</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], W_1),</span></span>
<span class="line wgss-stack-line" data-path-index="1" data-match-path-indices="1,3" data-weight-contributor="q" tabindex="0" role="button" aria-label="After pop: stack 0, 4, 9 with weight W_2" aria-pressed="false"><span style="color:#24292E;--shiki-dark:#E1E4E8">    ([</span><span style="color:#005CC5;--shiki-dark:#79B8FF">0</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">4</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">9</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], W_2),</span></span>
<span class="line wgss-stack-line" data-path-index="2" data-match-path-indices="2" data-weight-contributor="r" tabindex="0" role="button" aria-label="After pop: stack 0, 4, 10 with weight W_3" aria-pressed="false"><span style="color:#24292E;--shiki-dark:#E1E4E8">    ([</span><span style="color:#005CC5;--shiki-dark:#79B8FF">0</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">4</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">10</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], W_3),</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">])</span></span></code></pre>
      <svg class="article-wgss-join article-wgss-interactive-graph" data-weighted-gss-join-separate-graph role="img" aria-label="The result of the pop before weights are joined. Separate W_1 and W_2 interface nodes both have an incoming edge 9 and an outgoing edge 4. The W_3 interface has an incoming edge 10 and an outgoing edge 4."></svg>
    </div>
    <div class="article-wgss-join-operation" aria-hidden="true">
      <span>COLLAPSE ↓</span>
    </div>
    <div class="article-wgss-join-stage">
      <pre class="astro-code wgss-stack-code article-wgss-stage-code"><code><span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">WeightedGSS.from_stacks([</span></span><span class="line wgss-stack-line wgss-stack-result-joined" data-path-index="3" data-match-path-indices="0,1,3" data-weight-contributor="both" tabindex="0" role="button" aria-label="Joined result: stack 0, 4, 9 with weight W_1 joined with W_2" aria-pressed="false"><span style="color:#24292E;--shiki-dark:#E1E4E8">    ([</span><span style="color:#005CC5;--shiki-dark:#79B8FF">0</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">4</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">9</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], </span><span class="wgss-result-weight" data-weight-part="p" style="color:#24292E;--shiki-dark:#E1E4E8">W_1</span><span class="wgss-result-weight" data-weight-part="join" style="color:#24292E;--shiki-dark:#E1E4E8"> ⊔ </span><span class="wgss-result-weight" data-weight-part="q" style="color:#24292E;--shiki-dark:#E1E4E8">W_2</span><span style="color:#24292E;--shiki-dark:#E1E4E8">),</span></span>
<span class="line wgss-stack-line" data-path-index="2" data-match-path-indices="2" data-weight-contributor="r" tabindex="0" role="button" aria-label="Joined result: stack 0, 4, 10 with weight W_3" aria-pressed="false"><span style="color:#24292E;--shiki-dark:#E1E4E8">    ([</span><span style="color:#005CC5;--shiki-dark:#79B8FF">0</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">4</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#005CC5;--shiki-dark:#79B8FF">10</span><span style="color:#24292E;--shiki-dark:#E1E4E8">], W_3),</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">])</span></span></code></pre>
      <svg class="article-wgss-join article-wgss-interactive-graph" data-weighted-gss-join-after-graph role="img" aria-label="After popping 18, 27, and 35 independently, root edges 9 and 10 lead to the joined W_1 and W_2 interface and the separate W_3 interface. Both interface nodes then use edge 4 to reach the same lower node. The root is on the right."></svg>
    </div>
  </div>
</figure>

## Motivation

`weighted-gss` grew out of some awkward state I needed to track in [`glrmask`](https://github.com/IsaacBreen/glrmask). In general, `WeightedGSS` does not care what the weight is, as long as it implements a join operation. In `glrmask`, for reasons I will not get into deeply here (see [GLRMask: Constrained Decoding with Weighted Automata](/blog/constrained-decoding-with-weighted-automata/)), the weights are disallowed-terminals maps used to preserve greedy lexer semantics.

That left the data structure with a few requirements:

- Rather obviously, stacks have no stable identity apart from their paths through the GSS, so their weights must be encoded in those paths somehow.
- Every non-empty path through the GSS should contain exactly one weight-bearing node. The alternative would be distributing a weight across several pieces and somehow reconstructing it, which is a bit silly. So: one weight per path. If you think about what kind of shape this induces in the graph, the weight nodes form a kind of *interface* layer that cuts the graph in half, with all other nodes falling either above or below it. So our Upper/Interface/Lower node types arise naturally as a consequence of requiring one weight per path.
- Mask generation reads the weights frequently, so this interface should remain as close to the root as possible.
- The graph must support frequent incremental changes, including merges.

Much of the difficulty lies in lifting weights towards the root while keeping updates cheap. The graph should also share as much internal structure as possible without making pushes, pops, and merges expensive.

## Validation

A weighted GSS represents a set of stacks with a weight attached to each one. This makes it easy to test. We can write a slow implementation that stores every stack directly in a `dict[tuple[T, ...], W]`. The property tests and fuzzer apply the same random operations to both implementations, then expand the GSS and compare the resulting stack-to-weight mappings after every step.

```python
from collections.abc import Callable
from dataclasses import dataclass, field


type Stack[T] = tuple[T, ...]


@dataclass
class ExplicitStacks[T, W]:
    join: Callable[[W, W], W]
    stacks: dict[Stack[T], W] = field(default_factory=dict)

    def pop(self):
        result = ExplicitStacks(self.join)

        for stack, weight in self.stacks.items():
            if not stack:
                continue

            new_stack = stack[:-1]
            if new_stack in result.stacks:
                weight = self.join(result.stacks[new_stack], weight)
            result.stacks[new_stack] = weight

        return result
```

<section class="article-notes" aria-labelledby="weighted-gss-notes-heading">
  <h2 id="weighted-gss-notes-heading">Notes</h2>
  <ol>
    <li id="weighted-gss-note-1">
      <p>The figure shows each stack item as a separate node. The implementation packs deterministic runs into segments; its corresponding node types include weighted branches, weighted segments, <code>Shared</code> nodes, and unweighted nodes. Empty-stack alternatives are stored directly on weighted branch nodes and are not shown here.</p>
      <p>I made the executive decision to leave the arrows out of the main figure because its root is on the right, so every arrow would point left. For completeness, here is the same graph with them:</p>
      <figure class="article-figure article-figure--wgss-notes">
        <svg class="article-wgss-graph article-wgss-graph--notes" data-weighted-gss-article-graph-arrows role="img" aria-label="The same weighted graph-structured stack with arrows pointing from the root on the right towards the leaf on the left."></svg>
      </figure>
    </li>
    <li id="weighted-gss-note-2">
      <p>This matters because a normal LR parser spends most of its time pushing and popping a linear stack. Those operations are tiny. With a persistent GSS, even a simple push will generally construct a new node.</p>
      <p><code>weighted-gss</code> stores long deterministic runs as linear segments, including runs above or between ambiguous parts of the graph. When the top of the stack is linear, <code>LinearPrefix</code> lets the parser work against that segment and a small mutable push buffer. It only returns to the graph representation when it reaches an ambiguous part or needs to materialise the result. Most users will not need to think about this, but it leaves a much faster path available for the common case.</p>
    </li>
  </ol>
</section>
