Wuggy 2.0: graph-based core reengineering, sampling modes, terminology - #39
Open
uncle-hedgehog wants to merge 6 commits into
Open
Wuggy 2.0: graph-based core reengineering, sampling modes, terminology#39uncle-hedgehog wants to merge 6 commits into
uncle-hedgehog wants to merge 6 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
WuggyGeneratoris preserved; internals, dependencies, and some structure/attribute names change.What changed
Core representation (
SegmentGraph, wasBigramChain). 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()(wasclean()) 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.pklnext 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 — withSegmentGraphreduced 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; seedocumentation/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),
getattrinstead ofeval, one-level match copies instead ofdeepcopy, streamed file parsing.Levenshtein statistics.
old20,ned1, andld1nnnow use rapidfuzz's batched SIMD distance;python-Levenshteinis 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 throughgenerate_advanced(mode=)andgenerate_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_classicmatch dicts are no longer deep-copied; and the token-frequency fix changes numeric output whentoken=True.Verification
A committed harness (
tests/capture_behavior.pyover 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.pyverifies 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
7525616, thetoken=Truefix) with Fix token frequency being discarded in BigramChain.load #38. If Fix token frequency being discarded in BigramChain.load #38 merges first, this reduces to the 2.0 commits on top; if you prefer, that commit can be dropped here once Fix token frequency being discarded in BigramChain.load #38 lands.generator.bigramchainmust usegenerator.segment_graph. The official plugins inherit fromBaseLanguagePlugin(updated here); any that reference the old attribute directly need a matching update in thewuggy_language_plugin_datarepo when 2.0 ships.🤖 Generated with Claude Code