Skip to content

Wuggy 2.0: graph-based core reengineering, sampling modes, terminology - #39

Open
uncle-hedgehog wants to merge 6 commits into
masterfrom
claude/wuggy-2.0-reengineering
Open

Wuggy 2.0: graph-based core reengineering, sampling modes, terminology#39
uncle-hedgehog wants to merge 6 commits into
masterfrom
claude/wuggy-2.0-reengineering

Conversation

@uncle-hedgehog

@uncle-hedgehog uncle-hedgehog commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Major version release (2.0.0). Reengineers the pseudoword-generation core for efficiency, adds new generation capabilities, splits the core into a reusable kernel plus a linguistic layer, and aligns the code's vocabulary with the graph model described in the Wuggy literature. The public API of WuggyGenerator is preserved; internals, dependencies, and some structure/attribute names change.

What changed

Core representation (SegmentGraph, was BigramChain). The dict-of-dicts store keyed by namedtuples is replaced by a layered DAG over interned integer (position, segment) vertices held in flat NumPy arrays. Filters (attribute / frequency / segmentset) are now boolean masks over shared edge arrays instead of full graph copies; prune() (was clean()) is a single backward-reachability sweep instead of a recursive copy-until-fixpoint; generation is an iterative shuffled DFS. Parsed graphs are cached to a binary .graph.pkl next to the data file, keyed by mtime/size, so the corpus text is parsed once per version.

Kernel extraction (PositionalGraph). The graph mechanics are split into a domain-agnostic kernel (wuggy/utilities/positionalgraph.py) — interning, edge arrays, masks, pruning, exact counting, and the three generation modes — with SegmentGraph reduced to the linguistic layer on top (plugin-driven parsing, segment-field filters, cache reconstruction of plugin namedtuples). The kernel treats symbol payloads as opaque hashables and contains no linguistics; see documentation/design/ for the rationale (it is a weighted positional DAG / acyclic WFA with exact counting and unbiased constrained sampling, reusable beyond pseudowords).

Generator hot paths. Set-based sequence/lexicon caches (were O(n) list scans), getattr instead of eval, one-level match copies instead of deepcopy, streamed file parsing.

Levenshtein statistics. old20, ned1, and ld1nn now use rapidfuzz's batched SIMD distance; python-Levenshtein is dropped. Runtime deps are now numpy + rapidfuzz.

New generation capabilities. count_paths() returns the exact number of candidates a (filtered) subgraph can produce without generating them. generate() gains three modes, plumbed through generate_advanced(mode=) and generate_classic/generate_gui (search_mode=):

  • exhaustive — the classic shuffled DFS (default, unchanged);
  • uniform — every candidate exactly once in unbiased random order;
  • weighted — every candidate exactly once, drawn proportional to the product of its transition frequencies (most word-like first).

Both sampling modes draw without replacement via a shared backward DP plus a prefix trie of drawn paths, so each candidate is yielded exactly once and the concentric-search drain-then-widen loop still terminates correctly.

Terminology rename (breaking, no aliases). The code now matches the "directed graph of subsyllabic segments" description in the literature: BigramChain->SegmentGraph, Link->Vertex, clean->prune, startkeys->start_vertices, WuggyGenerator.bigramchain(s)->segment_graph(s), *_subchain->*_subgraph.

Why a major version

Internals are replaced wholesale; dependencies change (numpy + rapidfuzz replace python-Levenshtein); all edge frequencies are floats now; word-lexicon buckets are sets; generate_classic match dicts are no longer deep-copied; and the token-frequency fix changes numeric output when token=True.

Verification

A committed harness (tests/capture_behavior.py over a deterministic synthetic language) captures order-independent behavior — edge dumps in both weighting modes, start vertices, exhaustive generation sets (~870k sequences across 9 references), and every statistic. Output is byte-identical across the 1.x baseline, the core rewrite, the rename, and the kernel extraction. tests/test_sampling.py verifies exact path counts, set-equality of all three generate modes, seeded determinism, weighted-order bias, and generator API integration.

Benchmarks (synthetic 40k-word lexicon): candidate evaluation loop ~4.6x, ned1+old20 ~4.1x, band filtering ~3.5x, warm load ~8.8x.

Notes for reviewers

🤖 Generated with Claude Code

Emmanuel Keuleers and others added 6 commits July 18, 2026 15:48
An unconditional `frequency = 1` on the line after the token check
overwrote the parsed value, so `token=True` silently behaved like
type-based counting: bigram transition weights never reflected corpus
frequencies. Remove the stray reassignment so the token flag works as
documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BigramChain is rewritten as a layered DAG over interned (position,
segment) integer nodes with flat NumPy edge arrays. Filters
(attribute/frequency/segmentset) are now boolean edge masks over views
that share the node/edge store instead of dict-of-dict copies, clean()
is a single backward-reachability sweep instead of a recursive
copy-until-fixpoint pass, and generate() is an iterative shuffled DFS
instead of nested recursive generators.

Generator hot paths: sequence cache and word-lexicon buckets are sets
(were O(n) list scans), statistics/output modes resolve via getattr
(was eval), matches are built with one-level copies (was deepcopy), and
lexicons stream from disk instead of readlines(). Parsed chains are
cached in a binary .chain.pkl next to the data file, keyed by data file
mtime/size, so the text corpus is parsed once per version.

Levenshtein statistics (old20, ned1, ld1nn) now use rapidfuzz batched
SIMD distance computation; python-Levenshtein is dropped.

Behavioral equivalence with the previous implementation is verified by
tests/capture_behavior.py over a committed synthetic language: chain
edge dumps (type and token weighting), startkeys, exhaustive generation
sets (~870k sequences across 9 references), and all statistics are
byte-identical between old and new.

Benchmarks (synthetic 40k-word lexicon, M-series Mac):
  candidate loop w/ mandatory stats  4.6x faster
  ned1+old20 statistics              4.1x faster
  frequency band filtering           3.5x faster
  chain load (warm cache)            8.8x faster
  raw sequence generation            1.4x faster

Major version bump: internals and dependencies change (numpy+rapidfuzz
replace python-Levenshtein), all frequencies are floats now, word
lexicon buckets are sets, and generate_classic match dicts are no
longer deep-copied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DAG representation makes candidate sampling tractable, so generation
now supports three modes, exposed as generate(mode=...) on BigramChain,
generate_advanced(mode=...), and generate_classic/generate_gui
(search_mode=...):

  exhaustive  shuffled depth-first traversal (default; unchanged and
              still byte-identical to the 1.x behavior)
  uniform     every candidate exactly once, in unbiased random order
              (uniform sampling without replacement); removes the
              low-branching-subtree bias of DFS order
  weighted    every candidate exactly once, drawn with probability
              proportional to the product of its transition frequencies,
              so the most word-like candidates tend to arrive first

Both sampling modes draw without replacement: completion counts/weights
are computed once per subgraph by a backward DP sweep, and drawn paths
are recorded in a prefix trie whose removed mass is subtracted during
the walk (per-prefix, since suffix counts are shared across prefixes).
Integer counts guarantee exactly-once yields and termination, which
keeps the concentric-search loop's drain-then-widen contract intact.

BigramChain.count_paths() reports the exact number of candidates a
subgraph can produce without generating them.

tests/test_sampling.py verifies set-equality of all three modes over 10
filtered subgraphs, exact count_paths, seeded reproducibility, the
weighted-order bias, and generator API integration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The implementation now literally is the directed graph described in the
Wuggy literature, so the names follow suit. Breaking renames, no
compatibility aliases (2.0):

  BigramChain                    -> SegmentGraph
  wuggy/utilities/bigramchain.py -> wuggy/utilities/segmentgraph.py
  Link                           -> Vertex (it names a vertex id,
                                    not an edge)
  clean()                        -> prune()
  startkeys / set_startkeys      -> start_vertices / set_start_vertices
  WuggyGenerator.bigramchain(s)  -> segment_graph(s)
  attribute/frequency_subchain   -> attribute/frequency_subgraph
  'LinkError'                    -> 'EmptyGraphError'
  cache suffix .chain.pkl        -> .graph.pkl (format version bumped,
                                    old caches are re-parsed silently)

Note for language plugin authors: custom statistics that referenced
generator.bigramchain must use generator.segment_graph.

Sampling tests and the behavioral equivalence capture are byte-identical
before and after the rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Record the analysis that the 2.0 core is a domain-agnostic weighted
positional DAG (acyclic WFA) with exact path counting and unbiased
constrained sampling, its reusable applications, and its scope limits.
Include a task prompt for extracting the language-free kernel out from
under SegmentGraph as a verifiable, behavior-preserving refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SegmentGraph mixed two concerns: a generic weighted positional-DAG
engine and Wuggy's linguistic layer. This splits them at the seam
identified in documentation/design/positional-graph-abstraction.md,
with no change in observable behavior.

wuggy/utilities/positionalgraph.py now owns everything that has no
knowledge of language, treating each symbol payload as an opaque
hashable: vertex interning and (position, symbol) identity, the flat
edge arrays and edge_mask with position slices, the shared-store view
machinery (_view/_invalidate_caches/_edges/_adjacency), the mapping-
compat methods and display, set_start_vertices, get_frequencies,
frequency_filter, prune/_prune_in_place, count_paths and
_completion_counts, and all three generate modes. It gains three
generic filter primitives (edge_mask_filter, vertex_pass_filter,
vertex_predicate_filter), a generic loader (load_sequences, taking
(symbols, weight) pairs), and the array half of cache serialization
(_cache_arrays/_restore_arrays).

SegmentGraph subclasses the kernel and keeps only the linguistic
parts: load() parsing plugin data files via separator/transform,
attribute_filter and segmentset_filter re-expressed through
vertex_predicate_filter, build_limit_frequencies, and the
Segment/SegmentH namedtuple reconstruction half of the cache
round-trip (format unchanged, version 2, so existing caches load).

Subclassing was chosen over composition because every filter returns
a new graph object created inside kernel methods; with composition
each returned view would need re-wrapping plus a pile of forwarding
methods, whereas the kernel's _view() instantiating type(self) and a
single _share_domain_state() hook (carrying language_plugin,
hidden_sequence, limit_frequencies onto views) gives the same result
with no delegation layer. The kernel stores payloads as
vertex_symbols; SegmentGraph keeps a vertex_segments property as a
compat alias.

Two deliberate near-noop cleanups: set_start_vertices drops its dead
language_plugin.default_fields lookup (both parameters were already
unused), and load() now feeds the kernel through a generator while
preserving the exact per-line order of transform, cutoff, and
random.randint calls.

Verified: tests/capture_behavior.py output is byte-identical to the
pre-refactor baseline both when parsing fresh and when loading from
the pickle cache, and tests/test_sampling.py passes with identical
seeded statistics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant