Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
88bdc62
fix(collections): hash sets independently of element order
Trosper3 Oct 1, 2026
0f38e6f
fix(grammar): accept set edges in unorderedcross again
Trosper3 Oct 1, 2026
52b710f
fix(grammar): reject list edges in unorderedcross
Trosper3 Oct 1, 2026
aa5b2e2
fix(grammar): make is int, is set and is list check their type
Trosper3 Oct 1, 2026
8ba6e46
feat(parser): read instance strings in one pass
Trosper3 Oct 1, 2026
3935e69
fix(grammar): report shape and constraint failures as SpadeParseExcep…
Trosper3 Oct 1, 2026
b044325
Merge remote-tracking branch 'upstream/main' into fix/9-set-hashing
Trosper3 Oct 12, 2026
75a183e
Merge branch 'fix/9-set-hashing' into fix/12-unorderedcross-edges
Trosper3 Oct 12, 2026
bf35f77
Merge branch 'fix/12-unorderedcross-edges' into fix/10-type-constraints
Trosper3 Oct 12, 2026
cfe1889
Merge branch 'fix/10-type-constraints' into fix/2-instance-scanner
Trosper3 Oct 12, 2026
ac55d26
ci: run the Redux Build System (rbs) report, soft for now
Trosper3 Oct 15, 2026
4d63d9a
style: run dotnet format
Trosper3 Oct 15, 2026
1f883b0
Merge branch 'ci/rbs' into fix/9-set-hashing
Trosper3 Oct 15, 2026
e895be2
test: add the test project to the solution and collect coverage
Trosper3 Oct 15, 2026
89de00a
Merge branch 'fix/9-set-hashing' into fix/12-unorderedcross-edges
Trosper3 Oct 15, 2026
6e3c2d0
Merge branch 'fix/12-unorderedcross-edges' into fix/10-type-constraints
Trosper3 Oct 15, 2026
c6e951c
Merge branch 'fix/10-type-constraints' into fix/2-instance-scanner
Trosper3 Oct 15, 2026
8f68aa3
style: run dotnet format
Trosper3 Oct 15, 2026
7145b7d
ci: run the devcontainer on SDK 10 alone
Trosper3 Oct 15, 2026
75ed99a
Merge branch 'ci/rbs' into fix/9-set-hashing
Trosper3 Oct 15, 2026
8829639
test: run the tests on .NET 10
Trosper3 Oct 15, 2026
23a93e3
Merge branch 'fix/9-set-hashing' into fix/12-unorderedcross-edges
Trosper3 Oct 15, 2026
0a3262d
Merge branch 'fix/12-unorderedcross-edges' into fix/10-type-constraints
Trosper3 Oct 15, 2026
ad07a09
Merge branch 'fix/10-type-constraints' into fix/2-instance-scanner
Trosper3 Oct 15, 2026
082150d
ci: keep the coverage note in rbs.toml free of a per-branch figure
Trosper3 Oct 15, 2026
5ce502a
Merge branch 'fix/9-set-hashing' into fix/12-unorderedcross-edges
Trosper3 Oct 15, 2026
0302438
Merge branch 'fix/12-unorderedcross-edges' into fix/10-type-constraints
Trosper3 Oct 15, 2026
3ee32cb
Merge branch 'fix/10-type-constraints' into fix/2-instance-scanner
Trosper3 Oct 15, 2026
278e91f
Merge remote-tracking branch 'rx/main' into ci/rbs
Trosper3 Oct 15, 2026
32af284
Merge branch 'ci/rbs' into fix/9-set-hashing
Trosper3 Oct 15, 2026
0e82c8a
Merge branch 'fix/9-set-hashing' into fix/12-unorderedcross-edges
Trosper3 Oct 15, 2026
26214bd
Merge branch 'fix/12-unorderedcross-edges' into fix/10-type-constraints
Trosper3 Oct 15, 2026
c8f3b9d
Merge branch 'fix/10-type-constraints' into fix/2-instance-scanner
Trosper3 Oct 15, 2026
dbc816b
fix(collections): freeze a collection once it is hashed
Trosper3 Oct 15, 2026
1a78795
Merge branch 'fix/9-set-hashing' into fix/12-unorderedcross-edges
Trosper3 Oct 15, 2026
62cc7d5
Merge branch 'fix/12-unorderedcross-edges' into fix/10-type-constraints
Trosper3 Oct 15, 2026
d3a486c
Merge branch 'fix/10-type-constraints' into fix/2-instance-scanner
Trosper3 Oct 15, 2026
45eea96
fix(collections): make the cached hash safe to read from several threads
Trosper3 Oct 16, 2026
8be5c33
Merge branch 'fix/9-set-hashing' into fix/12-unorderedcross-edges
Trosper3 Oct 16, 2026
b84c517
Merge branch 'fix/12-unorderedcross-edges' into fix/10-type-constraints
Trosper3 Oct 16, 2026
a5d2b6c
Merge branch 'fix/10-type-constraints' into fix/2-instance-scanner
Trosper3 Oct 16, 2026
2b8b018
feat(collections): add Freeze() and document using SPADE from several…
Trosper3 Oct 16, 2026
107ff3b
Merge branch 'fix/9-set-hashing' into fix/12-unorderedcross-edges
Trosper3 Oct 16, 2026
1c29c7e
Merge branch 'fix/12-unorderedcross-edges' into fix/10-type-constraints
Trosper3 Oct 16, 2026
9427f94
Merge branch 'fix/10-type-constraints' into fix/2-instance-scanner
Trosper3 Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "SPADE .NET Dev Container",
// SDK 10 reads SPADE.slnx (the .slnx format needs SDK 9.0.200+) and still builds the net6.0 library.
"image": "mcr.microsoft.com/dotnet/sdk:10.0",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"username": "vscode",
"installZsh": false,
"upgradePackages": false
},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": { "moby": false },
"ghcr.io/reduxisu/features/rbs:1": {}
},
"remoteUser": "vscode",
"customizations": {
"vscode": {
"extensions": [
"ms-dotnettools.csharp"
]
}
},
"postStartCommand": "if [ \"$(stat -c %U /workspaces/SPADE)\" != \"vscode\" ]; then sudo chown -R vscode:vscode /workspaces/SPADE; fi",
"postCreateCommand": "dotnet --list-sdks && dotnet --list-runtimes"
}
4 changes: 1 addition & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: |
6.0.x
8.0.x
dotnet-version: 10.0.x
- name: Build
run: dotnet build DiscreteParser.csproj -c Release
# The test project arrives with the first fix PR; until then there is nothing to run.
Expand Down
4 changes: 1 addition & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: |
6.0.x
8.0.x
dotnet-version: 10.0.x
- name: Check the tag matches the project version
run: |
version=$(grep -oP '(?<=<Version>)[^<]+' DiscreteParser.csproj)
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/rbs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: rbs

on:
pull_request:
push:
branches: [main]

permissions:
contents: read
pull-requests: write
packages: write

concurrency:
group: rbs-${{ github.ref }}
cancel-in-progress: true

jobs:
build-test:
uses: ReduxISU/Redux_Build_System/.github/workflows/ci.yml@main
secrets: inherit
with:
# Report only while ci.yml is still the gate.
soft: true
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
bin/
obj/
DF.sln
.rbs/
report.md
2 changes: 2 additions & 0 deletions DiscreteParser.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<Authors>Russell Phillips</Authors>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
<!-- The test project has its own csproj; keep its files out of the library. -->
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
</PropertyGroup>

<ItemGroup>
Expand Down
29 changes: 28 additions & 1 deletion Documentation/InstanceString.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,31 @@ So a set may look something like {a,b,c}. A list **V**, will be represented usin
```
(e_0,...,e_k)
```
as such, an example of a list could look something like (1,2,3). Also note that since an element of a set or list may also be a set of a list, so a set of lists can be represented as {(1,2),(2,3)}.
as such, an example of a list could look something like (1,2,3). Also note that since an element of a set or list may also be a set of a list, so a set of lists can be represented as {(1,2),(2,3)}.

## Whitespace
Whitespace is ignored where it can't be part of a value, so an instance can be spread over several lines:

```
{
(1, 2),
(2, 3)
}
```

reads the same as `{(1,2),(2,3)}`. The rules are:

- Spaces, tabs and line breaks before or after `{ } ( ) ,`, and at the start and end of the input, are skipped.
- Inside a value, whitespace next to `[ ] ; : =` is dropped, so `x1 : True` reads as `x1:True`.
- Other whitespace inside a value is kept. Plain spaces stay as typed, so `{New York, Los Angeles}` holds `New York`. A run containing a tab or line break becomes a single space, so a value wrapped onto two lines, `New` and `York`, reads as `New York`.

## Errors
An instance string that can't be read throws `SpadeParseException`, which says where the problem is, what was expected and what was found:

```
expected , or } at position 5, found end of input
```

for `{1,2`. Its `Position` (counting from 1), `Expected` and `Found` properties hold the same details. Nothing is guessed: unbalanced braces, extra text after the instance, empty elements such as `{1,,2}`, and empty input are all errors.

Sets and lists can be nested up to 256 levels deep. Deeper input is rejected with a `SpadeParseException` rather than risking a stack overflow.
9 changes: 9 additions & 0 deletions Documentation/development.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# Development
Feel free to contribute! Also feel free to open any issues on [github](https://github.com/Jetison333/SPADE) with bugs or feature requests.

## Tests
The tests live in tests/SPADE.Tests and use xUnit. Run them from the repository root with

```bash
dotnet test tests/SPADE.Tests
```

The test project targets .NET 10, so you need the .NET 10 SDK or newer. The library itself still targets .NET 6, and SDK 10 builds it.


## antlr
If you need to edit the grammar inside of coll.g4 file, use the following commands (you will need to install antlr4):
Expand Down
19 changes: 19 additions & 0 deletions Documentation/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,22 @@ foreach (UtilCollection c in graph["E"])
```

Above is example code for parsing a graph type problem. The StringParser class is provided with a [definition string](DefinitionString.md), and then with an [instance string](InstanceString.md). It then parses and verifies the instance string. Now, you can access items of that problem by using a key access, which returns a [UtilCollection](utilcollection.md) class, which can then be further used however you need to use it.

## Handling bad input

Every problem with an instance string throws `SpadeParseException`: text that can't be read, a shape that doesn't match the definition string, or a failed constraint. Catch it to report bad input, for example as a 400 response:

```c#
try
{
graph.parse(instanceString);
}
catch (SpadeParseException e)
{
// e.g. "expected , or } at position 5, found end of input"
// or "Constraint E subset N cross N failed: (A,X) is not a member"
Console.WriteLine(e.Message);
}
```

Any other exception type means a bug in SPADE or in the definition string, not bad input.
1 change: 1 addition & 0 deletions Documentation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ SPADE is a tool that parses strings representing instances of discrete math type
- [Instance Strings](InstanceString.md)
- [Definition String](DefinitionString.md)
- [Utilcollections](utilcollection.md)
- [Using SPADE from several threads](threads.md)
- [SPADE Development](development.md)
35 changes: 35 additions & 0 deletions Documentation/threads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Using SPADE from several threads

SPADE's objects follow the usual .NET rule: build on one thread, then share. Once an instance has been parsed and frozen, any number of threads can read it at the same time.

## Safe to share

- **A frozen `UtilCollection`.** Hashing, `Equals`, `Contains`, `Count`, indexing, enumerating and `ToString` are all safe from several threads at once. A frozen collection can't change, and its hash is computed once and published safely.
- **Sets as lookup tables.** Everything inside a set is frozen as soon as it goes in, so a shared `HashSet<UtilCollection>` can't be changed under another thread (as long as no thread adds to the set itself).

## Not safe to share

- **A `StringParser` while it parses.** `parse()` replaces the parser's results, so two threads parsing with the same `StringParser` overwrite each other. Use one `StringParser` per thread, or per parse; they are cheap to create. Reading the results of a finished parse from several threads is fine.
- **A collection that is still being built.** Don't call `Add`, or assign to an index, while another thread reads the collection. The same is true of `List` and `HashSet`.

## Recommended pattern

Parse once, freeze the result, then hand it to the workers:

```c#
StringParser parser = new("{((N,E),K) | N is set, E subset N unorderedcross N, K is int}");
parser.parse(instance);

UtilCollection nodes = parser["N"].Freeze();
UtilCollection edges = parser["E"].Freeze();

Dictionary<string, int> degree = new();
Parallel.ForEach(nodes.ToList(), node =>
{
// Every thread reads the same frozen collections.
int d = edges.ToList().Count(edge => edge.Contains(node));
lock (degree) degree[node.ToString()] = d;
});
```

`Freeze()` freezes a collection and everything inside it, and returns the same collection. After that, any attempt to change it throws `InvalidOperationException`, so a stray `Add` in a worker fails loudly instead of corrupting shared data. `IsFrozen()` tells you whether a collection is frozen.
24 changes: 21 additions & 3 deletions Documentation/utilcollection.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Hamiltonian path is a graph problem, and the string follows the formal definitio
UtilCollections provides a few Utility methods to help process and extract the instance information. Each method works for both ordered and unordered collections, unless specified otherwise.

### Indexing
If the collection is ordered, then you can index into the list using square brackets to get each element of the list, the same as you would for an array. Returns a UtilCollections object.
If the collection is ordered, then you can index into the list using square brackets to get each element of the list, the same as you would for an array. Returns a UtilCollections object. Assigning to an index (`list[0] = item`) replaces that element, unless the list has been hashed (see [Collections freeze once hashed](#collections-freeze-once-hashed)).

### Enumeration
Using foreach loops to iterate over each element in the collection is also possible, for example, using a UtilCollection object named collection:
Expand All @@ -57,7 +57,7 @@ Returns True if the two UtilCollections objects are equal to each other and Fals
Returns the length of the collection.

### ToList
Returns a list representing the elements of the collection. If the collection is ordered, it returns the same list. If the collection is unordered, it is cast to a list and then returned.
Returns a list representing the elements of the collection. If the collection is ordered, it returns a copy of the list, so changing the copy doesn't change the collection. If the collection is unordered, it is cast to a list and then returned.

### ToString
Returns the string representation of the UtilCollection.
Expand All @@ -66,7 +66,25 @@ Returns the string representation of the UtilCollection.
Checks if the collection contains another UtilCollection object. Comparison is done by value. Returns true if it contains it, false if it doesn't.

### Add
Adds a UtilCollection object to the collection.
Adds a UtilCollection object to the collection. Throws `InvalidOperationException` if the collection has been hashed (see below).

### Collections freeze once hashed
A `HashSet` or `Dictionary` files each element under the hash code it had when it went in. If the element changed afterwards, it would sit under a stale hash: `Contains` would miss it and duplicates could slip in, with no error. So the first time a UtilCollection is hashed, it freezes, and `Add` or assigning to an index throws `InvalidOperationException`. That happens when it is added to a set (including a UtilCollection set), used as a dictionary key, or put inside a list or set that is itself hashed, since hashing a collection hashes everything inside it.

To freeze a collection on purpose, call `Freeze()`. It freezes the collection and everything inside it, and returns the collection, so `new UtilCollection(text).Freeze()` works. `IsFrozen()` says whether a collection is frozen.

Build each collection fully, then add it to a set:

```c#
UtilCollection edge = new UtilCollection("{}");
edge.Add(new UtilCollection("1"));
edge.Add(new UtilCollection("2"));
edges.Add(edge); // edge is frozen from here on
```

A collection that has never been hashed can still be changed freely. The constructors that take a `List<UtilCollection>` or `HashSet<UtilCollection>` copy it, so changing the original afterwards doesn't change the collection.

A frozen collection can't change, so it is safe to share between threads: hashing, comparing, `Contains` and enumerating it from several threads at once all work. Building a collection is not thread-safe, the same as `List` and `HashSet`: fill it on one thread, then share it.

### parseInt
Attempts to parse and return itself as an integer.
Expand Down
152 changes: 152 additions & 0 deletions InstanceScanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System.Text;

namespace SPADE;

/// <summary>
/// Reads an instance string such as <c>({1,2},{(1,2)})</c> into a <see cref="UtilCollection"/>
/// in one pass, so the time taken grows in proportion to the input's length.
/// </summary>
/// <remarks>
/// Whitespace rules (#2):
/// <list type="bullet">
/// <item>Whitespace before or after <c>{ } ( ) ,</c> and at either end of the input is skipped.</item>
/// <item>Inside a value, whitespace next to <c>[ ] ; : =</c> is dropped, so <c>x1 : True</c> reads as <c>x1:True</c>.</item>
/// <item>Any other whitespace inside a value is kept. A run of plain spaces stays exactly as typed
/// (<c>New York</c>); a run containing a tab or line break becomes one space, since a line break
/// inside a value is a wrap (<c>New\nYork</c> reads as <c>New York</c>).</item>
/// </list>
/// These match the rules Redux applied before parsing, so that layer can be removed.
/// </remarks>
internal class InstanceScanner
{
/// <summary>
/// Deepest nesting of sets and lists accepted. Far beyond any real instance; it stops
/// deeply nested input from overflowing the stack and crashing the process (#7).
/// </summary>
public const int MaxDepth = 256;

// Characters that end a value.
private const string Structural = "{}(),";

// Characters inside a value that whitespace never belongs next to.
private const string ValueDelimiters = "[];:=";

private readonly string text;
private int pos;

private InstanceScanner(string text)
{
this.text = text;
}

public static UtilCollection Parse(string text)
{
if (text == null) throw new ArgumentNullException(nameof(text));
return new InstanceScanner(text).ParseInstance();
}

private UtilCollection ParseInstance()
{
SkipWhitespace();
if (AtEnd()) throw Error("a value, { or (");
UtilCollection result = ParseElement(0);
SkipWhitespace();
if (!AtEnd()) throw Error("end of input");
return result;
}

// Expects pos at the first character of an element, after any whitespace.
private UtilCollection ParseElement(int depth)
{
char c = text[pos];
if (c == '{' || c == '(') return ParseCollection(depth + 1);
if (Structural.IndexOf(c) >= 0) throw Error("a value, { or (");
return ParseValue();
}

private UtilCollection ParseCollection(int depth)
{
if (depth > MaxDepth) throw Error($"at most {MaxDepth} levels of nesting");

char open = text[pos];
char close = open == '{' ? '}' : ')';
pos++;

List<UtilCollection> items = new();
SkipWhitespace();
if (!AtEnd() && text[pos] == close)
{
pos++;
}
else
{
while (true)
{
SkipWhitespace();
if (AtEnd()) throw Error("a value, { or (");
items.Add(ParseElement(depth));

SkipWhitespace();
if (AtEnd() || (text[pos] != ',' && text[pos] != close)) throw Error($", or {close}");
if (text[pos++] == close) break;
}
}

if (open == '(') return new UtilCollection(items);
return new UtilCollection(items.ToHashSet());
}

private UtilCollection ParseValue()
{
int start = pos;
while (!AtEnd() && Structural.IndexOf(text[pos]) < 0) pos++;
return UtilCollection.FromValue(NormalizeValue(start, pos));
}

// Applies the whitespace rules to text[start..end). text[start] is never whitespace.
private string NormalizeValue(int start, int end)
{
StringBuilder sb = new(end - start);
int i = start;
while (i < end)
{
if (!char.IsWhiteSpace(text[i]))
{
sb.Append(text[i++]);
continue;
}

int j = i;
bool plainSpaces = true;
while (j < end && char.IsWhiteSpace(text[j]))
{
if (text[j] != ' ') plainSpaces = false;
j++;
}

bool trailing = j == end;
bool besideDelimiter = ValueDelimiters.IndexOf(sb[sb.Length - 1]) >= 0
|| (!trailing && ValueDelimiters.IndexOf(text[j]) >= 0);
if (!trailing && !besideDelimiter)
{
if (plainSpaces) sb.Append(text, i, j - i);
else sb.Append(' ');
}
i = j;
}
return sb.ToString();
}

private void SkipWhitespace()
{
while (!AtEnd() && char.IsWhiteSpace(text[pos])) pos++;
}

private bool AtEnd() => pos >= text.Length;

private SpadeParseException Error(string expected)
{
string found = AtEnd() ? "end of input" : text[pos].ToString();
return new SpadeParseException(pos + 1, expected, found);
}
}
Loading
Loading