Skip to content

feat(definition): describe a definition's parts without parsing an instance - #24

Draft
Trosper3 wants to merge 66 commits into
ReduxISU:mainfrom
Trosper3:feat/18-describe-definition
Draft

Trosper3 wants to merge 66 commits into
ReduxISU:mainfrom
Trosper3:feat/18-describe-definition

Conversation

@Trosper3

@Trosper3 Trosper3 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Lets a caller see what a definition string declares (its names, their types, and where each sits in the format) without parsing an instance first.

Stacked on #17 — review only the last two commits, feat(definition): describe a definition's parts without parsing an instance and fix(parser): reject malformed definition strings instead of guessing; the diff will shrink as #11–#17 merge.

Features

  • StringParser.Definition describes the definition string: new StringParser("{((N,E),K) | N is set, E subset N unorderedcross N, K is int}").Definition.
    • Outline is the format with spacing removed, e.g. ((N,E),K).
    • Parts lists N, E and K in outline order; d["E"] looks one up by name (Contains and TryGetPart don't throw).
    • Each part has a Kind (Set, Multiset, List, Int, Subset, Member, or Any), Of (what it's a subset or member of, as written, e.g. N unorderedcross N), Path (child indices from the outline's root, e.g. [0,1] for E), and Constraints (every constraint naming it, as written).
    • is set / is multiset / is list / is int decide the kind; a subset constraint alongside them still fills in Of, so S is multiset, S subset int gives a Multiset of int.
    • Only the outer format's names are parts. Names inside a nested set, like e and w in E subset {(e,w) | ...}, stay inside E's Of text.
    • and / or / not constraints are listed under each part they constrain but don't change its kind. A word SPADE doesn't know yet, like is string, leaves the kind as Any.
  • Malformed definitions are rejected. new StringParser(...) now throws the same FormatException as Definition when the definition string can't be read, naming the string and where the first error is, e.g. Definition string "{N | N is set" can't be read: mismatched input '<EOF>' expecting {'}', CM} at position 14. Before, a missing brace ({N | N is set), a stray character ({N | N is set#}) or an extra brace ({N | N is set}}) was printed to the console and guessed at. Nothing is printed now.
  • Read on first use. Definition is worked out the first time you use it. DefinitionDescription.Parse(definition) reads a definition string without a StringParser and throws the same error.
  • Nothing else changes for well-formed definitions. parse() gives the same results; it now reuses the definition read when the parser was made instead of reading it again on every call. The instance reader is untouched.
  • Docs: a "Describing a definition string" section in Documentation/example.md.
  • Tests: 31 new in DefinitionDescriptionTests.cs, covering the Redux definitions from Describe a definition's names and types without parsing an instance #18 and the malformed definitions above (exact messages, the same error from StringParser and Definition, nothing printed, valid definitions still parsing); 182 pass in total.

Issue Handling

Equal sets such as {1,2} and {2,1} used to get different hash codes,
because GetHashCode hashed ToString(), which lists elements in the
order they were written. Any set of sets could then miss a lookup:
{{1,2}}.Contains({2,1}) was False and {{1,2},{2,1}} kept both.

Sets now combine their element hashes with an order-independent sum,
lists hash their elements in order, and values hash their text.

Equals now also checks that both sides are the same kind (value, list
or set). Before, an empty set compared equal to an empty list and to
any plain value.

Adds a test project (tests/SPADE.Tests, xUnit) with tests for both.

Closes ReduxISU#9
Since d7e982e, an unorderedcross membership check rejected every
normal undirected edge such as {a,b}. The added line checked whether
each node inside the edge was a list, and plain nodes never are.

The check now looks at the edge itself: a plain value is not an
edge, so it fails the constraint instead of crashing while being
enumerated. List edges such as (a,b) are still accepted, as in the
released 0.1.2, because existing grammars for directed graphs rely
on it.

Closes ReduxISU#12
An unorderedcross member is an unordered pair, so it must be a set
such as {a,b}. A list such as (a,b) is an ordered pair, which is what
cross describes, so it is now rejected here too. This is what
d7e982e set out to do.

Grammars for directed graphs that use unorderedcross with list edges
need to switch to cross.

Refs ReduxISU#12
"N is int" checked nothing at all: int reaches the constraint as a set
descriptor, and the "is" branch only handled the set, list and int
keywords. So abc, {1} and (1,2) all passed. It now requires a single
value that parses as an integer.

"S is set" accepted a plain value, because a value is not ordered. It
now requires an unordered collection.

"is list" already rejected values and sets; it is now covered by tests.

Closes ReduxISU#10
Replaces the substring-based reader in UtilCollection(string) with
InstanceScanner, which walks the input once.

Whitespace (ReduxISU#2): spaces, tabs and line breaks around { } ( ) , and at
either end of the input are skipped, so {2, 3} now holds 3 and a
trailing new line no longer breaks an instance. Inside a value,
whitespace next to [ ] ; : = is dropped, plain spaces are kept (New
York), and a run with a tab or line break becomes one space. These
match the rules Redux applies before parsing today.

Errors (ReduxISU#4): malformed input throws SpadeParseException with the
position, what was expected and what was found, e.g. "expected , or }
at position 5, found end of input" for {1,2. Input that used to be
misread without an error, such as {1,2 or {1,2}junk, is now rejected.

Time and depth (ReduxISU#7): parsing is linear in the input's length, and
ToString builds its text in one buffer. Nesting deeper than 256 levels
is rejected instead of overflowing the stack and crashing the process.
40,000 elements took 104 seconds before; 100,000 now take well under
a second.

Closes ReduxISU#2
Closes ReduxISU#7
Refs ReduxISU#4
…tion

Input that is well formed but doesn't fit the definition string threw
whatever happened to fail: InvalidOperationException ("Cannot index
into a set"), ArgumentOutOfRangeException, or a bare Exception whose
message said PLACEHOLDER. All of these now throw SpadeParseException
with a message that names what failed:

- A format such as (N,E) checks that it got a list of exactly that
  many elements. A longer list used to be accepted and the extra
  elements dropped.
- Constraint failures quote the constraint as written, e.g.
  "Constraint E subset N unorderedcross N failed: {1,9} is not a
  member". Long values are shortened.
- subset on a plain value, and cross on anything that isn't a pair,
  fail the constraint instead of crashing.

Closes ReduxISU#4
A set drops repeated elements, so {3,3,2,2} was read as {3,2}. Problems
defined on multisets, such as Partition and Subset Sum, then solved a
different instance from the one written.

A definition string can now mark a collection with "is multiset":

    {N | N is multiset, N subset int}

reads {3,3,2,2} as four numbers. It applies before the other
constraints wherever it is written. Without it, braces still mean a
set, so nothing changes for existing definitions or callers.

To make this possible, a set read from an instance string keeps its
elements as written, and AsMultiset() turns them into a multiset.
Multisets count every occurrence in Count(), enumeration and
ToString(), compare by count in any order, and have order-independent
hash codes. Union and Intersect use the larger and smaller count. New
CountOf() and IsSubsetOf() compare by count, so {7,7} is in {7,7,1}
but not in {7,1}. A set and a multiset are never equal, and "is set"
rejects a multiset.

Adds the multiset keyword to coll.g4; the lexer is regenerated with
ANTLR 4.13.2, the version that generated the existing files.

Closes ReduxISU#1
…stance

StringParser.Definition reads the definition string on first use and returns
its outline and each named part's kind, object, path and constraints, so a
caller can build an editor for a problem before any instance exists.
rbs runs audit, format-check, lint (dotnet build) and unit tests with a
coverage floor inside the repo's devcontainer, the same way it does for
Redux. It reports without failing (soft) while ci.yml stays the gate;
publish.yml keeps doing the NuGet release, since rbs has no NuGet push.

- SPADE.slnx: the dotnet engine needs a solution. It names only
  DiscreteParser.csproj for now; the test project joins it with the first
  fix PR. ci.yml and publish.yml name DiscreteParser.csproj explicitly, so
  the MSB1011 a bare `dotnet build` now gives does not affect them.
- rbs.toml: coverage floor 70; the goal is 80%+.
- .devcontainer: SDK 10 (reads .slnx, builds the net6.0 library) plus the
  .NET 8 runtime the tests run on.
- .gitignore: rbs's .rbs/ work directory and report.md.
Whitespace only: dotnet format SPADE.slnx, so rbs's format-check starts clean. Brace placement, indentation and blank lines; no code changes.
SPADE.slnx now names tests/SPADE.Tests, so rbs's unit-test stage runs it.
coverlet.collector writes the cobertura report the coverage floor reads;
without it the project gets no coverage gate at all. Same version as
Redux's test project.
Whitespace only: dotnet format SPADE.slnx on this branch's code.
Whitespace only: dotnet format SPADE.slnx on this branch's code.
Whitespace only: dotnet format SPADE.slnx on this branch's code.
A HashSet or Dictionary files each element under the hash it had when it
went in. UtilCollection is mutable, so changing a collection (or anything
nested in it) after it has been hashed left it under a stale hash:
Contains and equality lookups missed it and duplicates slipped in, with no
error.

The first GetHashCode() call now freezes the collection. A list's or set's
hash is built from its elements' GetHashCode(), so they freeze too, all the
way down. Add and the indexer setter throw InvalidOperationException on a
frozen collection. The hash is computed once and cached.

The List<UtilCollection> and HashSet<UtilCollection> constructors copy
their argument instead of keeping the caller's reference, and ToList() on
a list returns a copy, so a frozen collection can't be changed from
outside either.
Multisets go through the same Add guard, so a hashed multiset can't
change its occurrences or counts. FromSetItems now copies the item list it
keeps as the set's occurrences instead of holding the caller's list.

Tests cover a hashed multiset refusing Add, its elements freezing as they
go in, AsMultiset still working on a frozen set, and unhashed multisets
changing freely.
GetHashCode writes the hash and then sets frozen. Without a barrier,
another thread could see frozen before the hash and return 0. frozen is
now volatile, so a thread that sees it also sees the hash; two threads
computing it at once get the same value. A frozen collection can't
change, so it is safe to share between threads; building one still
isn't, as with List and HashSet. Documented, with a test that races
threads to hash a fresh nested set.
# Conflicts:
#	Documentation/utilcollection.md
… threads

Freezing used to happen only as a side effect of hashing, so a list that
was built but never hashed could still be changed while other threads
read it. Freeze() freezes a collection and everything inside it on
purpose and returns it; IsFrozen() reports it. Documentation/threads.md
says what is safe to share (frozen collections, sets) and what isn't
(a StringParser while it parses, a collection still being built), with
the parse-once, freeze, then share pattern.
@Trosper3 Trosper3 mentioned this pull request Oct 16, 2026
StringParser kept ANTLR's default error listener on the definition
string, which printed the error to the console and recovered with a best
guess, so "{N | N is set" (missing brace), "{N | N is set#}" and
"{N | N is set}}" were accepted. ReduxISU#4 rules out best-guess parsing, and
Definition already rejected the same strings.

The throwing listener moves out of DefinitionDescription into its own
internal class, with one Parse method both use. The StringParser
constructor now reads the definition string through it, so a malformed
one throws the same FormatException as Definition, naming the string and
the position of the first error, before any instance is parsed. Nothing
is printed. parse() walks the tree read at construction instead of
reading the definition string again on every call.
Trosper3 added a commit to Trosper3/SPADE that referenced this pull request Oct 16, 2026
StringParser now rejects a malformed definition string when it is made,
and DefinitionDescriptionTests (ReduxISU#24) checks the same three strings,
their exact messages and that nothing is printed. The skipped theory
here would only repeat them, so it goes; the three gaps left are ReduxISU#40,
ReduxISU#41 and ReduxISU#14.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Describe a definition's names and types without parsing an instance

1 participant