---
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₁"><path d="M839 682 Q857 682 894.5 681.5 Q932 681 949 681 Q1015 681 1034 683 Q1041 683 1044 680 Q1050 676 1045 656 Q1042 643 1039.5 640 Q1037 637 1028 637 Q965 637 931 590 Q905 546 818 393 Q731 240 659 113 Q587 -14 585 -15 Q579 -22 561 -22 Q546 -22 542 -17 Q539 -14 522.5 229.5 Q506 473 506 480 L494 462 Q472 425 366 239 Q335 184 297.5 118 Q260 52 242 20.5 Q224 -11 223 -12 Q217 -22 197 -22 Q178 -22 176 -15 Q176 -12 154 304.5 Q132 621 131 622 Q129 631 121 633.5 Q113 636 82 637 L58 637 Q52 643 51.5 645.5 Q51 648 54 664 Q58 677 64 683 L76 683 Q118 680 176 680 Q186 680 208 680.5 Q230 681 241 681 Q254 681 272.5 681.5 Q291 682 301 682.5 Q311 683 313 683 L323 683 Q329 677 329.5 674.5 Q330 672 327 656 Q323 642 318 637 L297 637 Q268 636 249 631 Q234 625 232 620 Q232 613 248.5 378 Q265 143 266 136 Q422 412 501 550 L499 587 Q496 629 489 632 Q483 636 447 637 Q419 637 417 644 Q415 647 418 660 Q423 680 427 682 Q430 683 458 682 Q492 680 553 680 Q582 680 621.5 681 Q661 682 677 682 Q694 682 694 675 Q694 671 691 657.5 Q688 644 687 643 Q685 637 661 637 Q609 635 599 622 Q596 619 597 615 Q597 603 613 376.5 Q629 150 629 138 L631 141 Q633 144 637.5 151.5 Q642 159 649 170.5 Q656 182 666.5 200 Q677 218 690 241 Q703 264 720.5 295 Q738 326 759 362 Q888 589 890 595 Q892 601 892 604 Q892 620 872.5 628.5 Q853 637 831 637 Q817 637 817 647 Q817 650 819 660 Q820 663 820.5 667 Q821 671 821.5 673 Q822 675 823 677 Q824 679 826 680 Q828 681 831 681.5 Q834 682 839 682 Z" transform="translate(20,20) scale(0.019858156028368795,-0.019858156028368795) translate(-550.5,-330.5)" data-weight-component="base" data-weight-anchor-x="20" data-weight-anchor-y="20"></path><path d="M416 0 Q399 3 257 3 Q117 3 100 0 L88 0 L88 46 L114 46 Q172 46 189 49 Q197 50 213 61 L213 578 Q211 578 205 575 Q159 557 102 556 L83 556 L83 602 L102 602 Q186 605 242 640 Q260 650 273 663 Q275 666 285 666 Q295 666 302 660 L302 361 L303 61 Q318 46 368 46 L401 46 L427 46 L427 0 L416 0 Z" transform="translate(30,26) scale(0.010510510510510511,-0.010510510510510511) translate(-255,-333)" data-weight-component="subscript" data-weight-anchor-x="30" data-weight-anchor-y="26"></path></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>
