pachet commited on
Commit
954412a
·
1 Parent(s): 117e903

Use public vo-regular-bp dependency

Browse files
requirements.txt CHANGED
@@ -6,4 +6,4 @@ numpy>=2.2
6
  torch>=2.3
7
  verovio>=6.2
8
  ./vendor/muses
9
- ./vendor/vo_regular_bp
 
6
  torch>=2.3
7
  verovio>=6.2
8
  ./vendor/muses
9
+ git+https://github.com/fpachet/vo-regular-bp.git
vendor/vo_regular_bp/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Francois Pachet
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/README.md DELETED
@@ -1,570 +0,0 @@
1
- # vo-regular-bp
2
-
3
- `vo-regular-bp` is a Python library for exact constrained generation from
4
- sparse variable-order context models, with support for positional masks, meter
5
- constraints, forbidden-substring constraints, and reusable order-stack
6
- backends.
7
-
8
- The core algorithm runs backward dynamic programming on the reachable product
9
- of a context graph and a deterministic acceptor. Sampling then chooses each next
10
- symbol proportionally to its model probability times the downstream beta value,
11
- so generated sequences are exact samples from the source model conditioned on
12
- the regular language whenever the constrained mass is nonzero.
13
-
14
- The repository also contains specialized engines and experiments for positional
15
- constraints and Continuator-style order-stack backoff policies.
16
-
17
- ## Install
18
-
19
- The package has no declared runtime dependencies and requires Python 3.10 or
20
- newer.
21
-
22
- For use from another application, install directly from GitHub:
23
-
24
- ```bash
25
- python -m pip install "vo-regular-bp @ git+https://github.com/fpachet/vo-regular-bp.git"
26
- ```
27
-
28
- To pin an application to a stable revision, append a branch, tag, or commit:
29
-
30
- ```bash
31
- python -m pip install "vo-regular-bp @ git+https://github.com/fpachet/vo-regular-bp.git@main"
32
- ```
33
-
34
- For local development:
35
-
36
- ```bash
37
- python -m pip install -e ".[test]"
38
- python -m pytest -q
39
- ```
40
-
41
- Library-oriented integration notes are in
42
- [`docs/library_usage.md`](docs/library_usage.md). The paper evaluation scripts
43
- are kept separately under [`paper/variable_order_regular_bp/`](paper/variable_order_regular_bp/).
44
- Optimization status and future performance ideas are tracked in
45
- [`docs/optimization_roadmap.md`](docs/optimization_roadmap.md).
46
- Virtual transformed-corpus augmentation is described in
47
- [`docs/virtual_data_augmentation.md`](docs/virtual_data_augmentation.md).
48
-
49
- ## License
50
-
51
- This project is released under the MIT License. See [`LICENSE`](LICENSE).
52
-
53
- ## Optimization Status
54
-
55
- The library includes several exactness-preserving optimizations: positional
56
- constraints are kept as time masks instead of DFA product state, MAXORDER /
57
- forbidden-substring constraints use a dense DFA when possible, order-stack
58
- sampling caches feasible candidate sets, and non-trace sampling avoids trace
59
- object allocation. See [`docs/optimization_roadmap.md`](docs/optimization_roadmap.md)
60
- for measured results, discarded experiments, and future optimization options.
61
-
62
- ## Quick Start
63
-
64
- ```python
65
- from vo_regular_bp import ContextGraph, positional_acceptor, run_bp
66
-
67
- graph = ContextGraph.from_counts({(): {"a": 10, "b": 1}}, max_order=0)
68
- acceptor = positional_acceptor(length=1, alphabet=graph.alphabet)
69
-
70
- bp = run_bp(graph, acceptor, length=1)
71
-
72
- print(bp.partition_function) # 1.0
73
- print(bp.conditional_probability(("a",))) # 10 / 11
74
- print(bp.sample(rng=0)) # exact constrained sample
75
- ```
76
-
77
- Regular constraints can be intersected. This example samples three iid symbols,
78
- forces the final symbol to be `"a"`, and rejects the substring `"bb"`.
79
-
80
- ```python
81
- from vo_regular_bp import (
82
- ContextGraph,
83
- all_of,
84
- forbidden_substring_acceptor,
85
- positional_acceptor,
86
- run_bp,
87
- )
88
-
89
- graph = ContextGraph.from_counts({(): {"a": 1, "b": 1}}, max_order=0)
90
- acceptor = all_of(
91
- positional_acceptor(3, {2: {"a"}}, alphabet=graph.alphabet),
92
- forbidden_substring_acceptor([("b", "b")], alphabet=graph.alphabet),
93
- )
94
-
95
- bp = run_bp(graph, acceptor, length=3)
96
- samples = bp.sample_many(5, rng=123)
97
-
98
- assert all(sample[-1] == "a" for sample in samples)
99
- assert all(("b", "b") not in zip(sample, sample[1:]) for sample in samples)
100
- ```
101
-
102
- ## Main Concepts
103
-
104
- `ContextGraph` stores a sparse probabilistic context model. It can be built from
105
- explicit counts/probabilities, unweighted or weighted sequences, or an explicit
106
- backoff mixture:
107
-
108
- - `ContextGraph.from_counts(...)`
109
- - `ContextGraph.from_probabilities(...)`
110
- - `ContextGraph.from_sequences(...)`
111
- - `ContextGraph.from_weighted_sequences(...)`
112
- - `ContextGraph.from_backoff_sequences(...)`
113
-
114
- `DFA` is the deterministic acceptor interface used by product BP. A transition
115
- returning `None` rejects that symbol from the current acceptor state. Acceptors
116
- may also expose `transition_weight(state, symbol) -> float` for soft regular
117
- constraints: BP multiplies the VOMM transition probability by this weight before
118
- normalizing. The default weight is `1.0`; a weight of `0.0` acts as a hard ban.
119
-
120
- Available acceptor helpers include:
121
-
122
- - `WeightedDFA`: convenience subclass for weighted regular constraints.
123
- - `positional_acceptor`: zero-based per-position allowed symbols.
124
- - `meter_acceptor`: finite meter/class patterns.
125
- - `cumulative_meter_acceptor`: cumulative-cost meter predicates.
126
- - `forbidden_substring_acceptor`: reject any forbidden substring.
127
- - `dense_forbidden_substring_acceptor`: finite-alphabet optimized variant.
128
- - `max_order_acceptor`: reject reference substrings of length `max_order + 1`.
129
- - `all_of`: intersect acceptors.
130
- - `true_acceptor`: accept every finite sequence.
131
-
132
- ## Engines
133
-
134
- ### Product BP
135
-
136
- `run_bp(graph, acceptor, length=...)` is the general exact engine. It performs
137
- backward DP over reachable `(context_state, acceptor_state)` product states and
138
- returns a `ProductBPResult` with:
139
-
140
- - `partition_function`: constrained probability mass.
141
- - `sample(...)` and `sample_many(...)`: exact conditional samples.
142
- - `conditional_probability(sequence)`: probability under the constrained
143
- distribution.
144
- - product-state and edge-count diagnostics for scalability studies.
145
-
146
- `sample_exact(...)` is a convenience wrapper that runs BP and draws one sample.
147
-
148
- ### Source Graph Minimization And Experimental Merging
149
-
150
- Exact source minimization is available as an optional compiled view. It is
151
- semantics-preserving and never enabled by default:
152
-
153
- ```python
154
- from vo_regular_bp import exact_context_graph_quotient_stats, minimize_context_graph
155
-
156
- stats = exact_context_graph_quotient_stats(graph)
157
- minimized = minimize_context_graph(graph)
158
- ```
159
-
160
- Order-stack preparation also accepts `minimize_source_graphs=True`, which
161
- minimizes the source graphs before running the ordinary exact BP backend. The
162
- training/count model remains uncompressed, and minimized fixed-order graphs are
163
- cached on the model for reuse.
164
-
165
- Approximate source merging is explicitly experimental and changes the source
166
- model. It lives outside the safe top-level API:
167
-
168
- ```python
169
- from vo_regular_bp.experimental import alergia_merge
170
-
171
- merged = alergia_merge(
172
- graph,
173
- alpha=0.01,
174
- min_support=10,
175
- recursive=True,
176
- transition_projection=lambda state, symbol, edge: (
177
- symbol - state[-1]
178
- if state and isinstance(state[-1], int) and isinstance(symbol, int)
179
- else symbol
180
- ),
181
- )
182
- ```
183
-
184
- The returned object is still a `ContextGraph`, so `run_bp(...)` remains exact
185
- with respect to the merged source model. This is not constrained-product
186
- minimization and is not applied automatically. The optional
187
- `symbol_projection` and `transition_projection` arguments are where a client
188
- supplies domain semantics for similarity; `transition_projection(state, symbol,
189
- edge)` handles context-relative abstractions and takes precedence when both are
190
- provided. The default compares raw emitted symbols.
191
-
192
- Order-stack models can opt into the same experiment by merging each fixed-order
193
- source graph independently, then using the ordinary backend:
194
-
195
- ```python
196
- from vo_regular_bp.experimental import alergia_merge_order_stack_model
197
-
198
- abstract_model = alergia_merge_order_stack_model(
199
- model,
200
- alpha=0.01,
201
- min_support=10,
202
- recursive=True,
203
- transition_projection=lambda state, symbol, edge: (
204
- symbol - state[-1]
205
- if state and isinstance(state[-1], int) and isinstance(symbol, int)
206
- else symbol
207
- ),
208
- )
209
-
210
- backend = prepare_constrained_order_stack(
211
- abstract_model,
212
- constraints,
213
- length=8,
214
- prefix=prefix,
215
- )
216
- ```
217
-
218
- This preserves the public order-stack sampling path and trace shape. It changes
219
- the source model explicitly: BP and order selection remain exact for the merged
220
- fixed-order graphs that are passed in.
221
-
222
- The ALERGIA implementation is optimized for projected comparisons by caching
223
- projected continuation counts, projected successor labels, and recursive
224
- pair-compatibility decisions within one merge call. It also tracks active
225
- merge classes incrementally instead of rebuilding class members during every
226
- candidate comparison. These are internal optimizations only: they do not change
227
- the explicit API or make approximate merging automatic.
228
-
229
- ### Positional BP
230
-
231
- `run_positional_bp(...)` is a no-DFA specialization for fixed-horizon
232
- time-indexed symbol masks. It is useful when the only constraints are
233
- positional, because the recursion ranges over context states rather than full
234
- context-acceptor products.
235
-
236
- `LazyBackoffContextModel` matches `ContextGraph.from_backoff_sequences(...)`
237
- semantically, but materializes outgoing edges only when reached by BP.
238
-
239
- ### Order-Stack BP
240
-
241
- `OrderStackModel` and the `run_order_stack_*` functions implement
242
- Continuator-style constrained policy backoff over a stack of fixed-order
243
- models:
244
-
245
- - `run_order_stack_bp`: positional constraints only.
246
- - `run_order_stack_dfa_bp`: regular DFA constraints, including weighted transitions.
247
- - `run_order_stack_masked_dfa_bp`: regular DFA plus positional masks.
248
-
249
- This is a generation policy, not exact conditioning of one fixed stochastic
250
- source. At each step, the engine computes feasible future mass for each order,
251
- then an order policy such as `LongestFeasiblePolicy` or
252
- `SingletonAvoidingBackoffPolicy` chooses among feasible candidate orders.
253
-
254
- The main result object reports `success_mass`, order-specific start masses,
255
- samples, and optional order traces.
256
-
257
- ### Public Constraint Backend
258
-
259
- `prepare_constrained_order_stack(...)` is the recommended library-facing entry
260
- point for order-stack generation. It accepts a model and a `ConstraintSet`,
261
- compiles positional constraints as time masks, compiles regular constraints as
262
- acceptors, runs the backend once, and returns a reusable sampler.
263
-
264
- ```python
265
- from vo_regular_bp import (
266
- ConstraintSet,
267
- LongestFeasiblePolicy,
268
- OrderStackModel,
269
- prepare_constrained_order_stack,
270
- prepare_constrained_order_stack_plan,
271
- )
272
-
273
- sequence = (60, 64, 67, 72, 76, 67, 71, 72)
274
- model = OrderStackModel.from_sequences([sequence], max_order=2)
275
- final_c = {pitch for pitch in sequence if pitch % 12 == 0}
276
-
277
- backend = prepare_constrained_order_stack(
278
- model,
279
- ConstraintSet(positional={3: final_c}),
280
- length=4,
281
- prefix=sequence[:2],
282
- policy=LongestFeasiblePolicy(),
283
- )
284
-
285
- generated = backend.sample_with_orders(rng=0)
286
- sample = generated.sequence
287
- orders = generated.orders
288
- assert sample[-1] % 12 == 0
289
- ```
290
-
291
- For convenience, `prepare_constrained_order_stack_from_sequences(...)` builds
292
- the `OrderStackModel` and prepares the backend in one call. The returned
293
- `ConstrainedOrderStackBackend` exposes `sample(...)`, `sample_many(...)`,
294
- `sample_with_orders(...)`, `sample_many_with_orders(...)`, `sample_with_trace(...)`,
295
- and a stable `diagnostics` object with context/product sizes and success mass
296
- when a regular backend is used.
297
-
298
- `run_constrained_order_stack(...)` remains available when callers need the raw
299
- internal BP result object.
300
-
301
- When the model, horizon, and constraints are fixed but many prefixes will be
302
- sampled, prepare a prefix-independent plan once and bind prefixes later:
303
-
304
- ```python
305
- plan = prepare_constrained_order_stack_plan(
306
- model,
307
- ConstraintSet(positional={3: final_c}),
308
- length=4,
309
- policy=LongestFeasiblePolicy(),
310
- )
311
-
312
- backend_a = plan.for_prefix(sequence[:2])
313
- backend_b = plan.for_prefix(sequence[2:4])
314
-
315
- sample_a = backend_a.sample(rng=0)
316
- sample_b = backend_b.sample(rng=1)
317
- ```
318
-
319
- For regular constraints, the plan owns the shared graph and backward-message
320
- caches. Binding a prefix warms the lazy beta messages reachable from that prefix,
321
- and later prefixes reuse overlapping cached product states. The old
322
- `prepare_constrained_order_stack(...)` API is now a convenience wrapper around
323
- this plan path when the model supports prefix-independent graph preparation.
324
-
325
- For variable-length Continuator-style suffixes, use
326
- `prepare_until_order_stack(...)`. It prepares one fixed-length constrained
327
- backend for each feasible length in `[min_length, max_length]`, samples a
328
- length by the sum of its positive start-order masses, then samples the suffix
329
- from that length backend. If only a backend success indicator is available, the
330
- length receives unit weight. The returned suffix does not include the prefix:
331
- the prefix is conditioning context only.
332
-
333
- ```python
334
- from vo_regular_bp import OrderStackModel, prepare_until_order_stack
335
-
336
- model = OrderStackModel.from_sequences([("A", "B", "C")], max_order=1)
337
- backend = prepare_until_order_stack(
338
- model,
339
- prefix=("A",),
340
- stop="C",
341
- min_length=1,
342
- max_length=3,
343
- )
344
-
345
- suffix = backend.sample(rng=0)
346
- assert suffix == ("B", "C")
347
- ```
348
-
349
- `stop` may be one symbol, an iterable/set of symbols, or a predicate
350
- `stop(symbol) -> bool`. The returned suffix includes the first generated stop
351
- symbol at the final position, and earlier positions are constrained not to
352
- satisfy `stop`. `prepare_until_end_order_stack(..., end_symbol=...)` is a
353
- convenience alias for learned END/sentinel stops. END remains forbidden in
354
- ordinary fixed-length sampling, but first-hit END generation explicitly allows
355
- the sentinel at the final stop position.
356
-
357
- `ConstraintSet` supports:
358
-
359
- - `positional`: per-time symbol masks or predicates.
360
- - `forbidden_substrings`: exact forbidden substring / MAXORDER constraints,
361
- compiled to a dense DFA when possible.
362
- - `regular_acceptors`: caller-supplied `DFA` or `WeightedDFA` instances. Soft
363
- transition weights are multiplied into BP; missing transitions or weight
364
- `0.0` reject the symbol.
365
- - `meter`: a `MeterConstraint` for finite per-symbol meter/class patterns.
366
- - `cumulative_meter`: a `CumulativeMeterConstraint` for duration/cost
367
- accumulation, bar-boundary predicates, final total cost, and optional padding
368
- symbols.
369
-
370
- The core API is intentionally not Continuator-specific; adapters for other
371
- projects can map their own event objects to symbols, meter classes, costs, or
372
- regular acceptors.
373
-
374
- All high-level constraints are over the generated suffix. Positional indices are
375
- zero-based within the generated sequence, not within the training corpus and not
376
- within `prefix + generated`.
377
-
378
- ### Constraint Builders
379
-
380
- Common constraints can be built and combined without manually constructing
381
- `ConstraintSet` objects:
382
-
383
- ```python
384
- from vo_regular_bp import (
385
- combine_constraints,
386
- final_pitch_class,
387
- avoid_copied_ngrams,
388
- )
389
-
390
- constraints = combine_constraints(
391
- final_pitch_class(0, length=32),
392
- avoid_copied_ngrams(reference_pitches, 5),
393
- )
394
- ```
395
-
396
- Builder helpers include:
397
-
398
- - `at_position(...)`
399
- - `final_symbol(...)` and `final_symbols(...)`
400
- - `final_pitch_class(...)`
401
- - `avoid_copied_ngrams(...)`
402
- - `meter_pattern(...)`
403
- - `cumulative_meter(...)`
404
- - `padded_duration_total(...)`
405
- - `combine_constraints(...)`
406
-
407
- ### Event Adapters
408
-
409
- External projects can keep rich event objects at their boundary and encode them
410
- as hashable symbols for the backend:
411
-
412
- ```python
413
- from dataclasses import dataclass
414
- from vo_regular_bp import (
415
- EventCodec,
416
- combine_constraints,
417
- final_pitch_class,
418
- prepare_constrained_order_stack_from_events,
419
- )
420
-
421
- @dataclass(frozen=True)
422
- class Note:
423
- pitch: int
424
- duration: int
425
-
426
- codec = EventCodec(
427
- event_to_symbol=lambda note: (note.pitch, note.duration),
428
- symbol_to_event=lambda symbol: Note(symbol[0], symbol[1]),
429
- )
430
-
431
- backend = prepare_constrained_order_stack_from_events(
432
- [training_notes],
433
- combine_constraints(
434
- final_pitch_class(0, length=8, symbol_to_pitch=lambda symbol: symbol[0]),
435
- ),
436
- codec=codec,
437
- max_order=3,
438
- length=8,
439
- prefix=prefix_notes,
440
- )
441
-
442
- generated = backend.sample_events_with_orders(rng=0)
443
- print(generated.events)
444
- print(generated.orders)
445
- ```
446
-
447
- See `examples/event_order_stack_backend.py` for a complete small example.
448
-
449
- ### Continuator-Style Facade
450
-
451
- For projects that want a Continuator-shaped entry point, the dependency-free
452
- facade in `vo_regular_bp.continuator` uses event sequences, a prefix, a horizon,
453
- and constraints:
454
-
455
- ```python
456
- from vo_regular_bp import (
457
- combine_constraints,
458
- duration_total_constraint,
459
- final_pitch_class_constraint,
460
- prepare_continuation_backend,
461
- prepare_continuation_plan,
462
- )
463
-
464
- constraints = combine_constraints(
465
- final_pitch_class_constraint(
466
- 0,
467
- horizon=8,
468
- symbol_to_pitch=lambda symbol: symbol[0],
469
- ),
470
- duration_total_constraint(
471
- 8,
472
- horizon=8,
473
- symbol_to_duration=lambda symbol: symbol[1],
474
- ),
475
- )
476
-
477
- backend = prepare_continuation_backend(
478
- [training_events],
479
- prefix=prefix_events,
480
- horizon=8,
481
- max_order=4,
482
- constraints=constraints,
483
- event_to_symbol=lambda event: (event.pitch, event.duration),
484
- )
485
-
486
- generated = backend.sample_events_with_orders(rng=0)
487
- print(generated.events)
488
- print(backend.diagnostics.as_dict())
489
- ```
490
-
491
- The facade defaults to `SingletonAvoidingBackoffPolicy`, matching the
492
- Continuator-style policy-backoff interpretation. Pass `policy=...` to use a
493
- different order-selection policy. See `examples/continuator_style_backend.py`
494
- for a complete dependency-free example.
495
-
496
- When making many calls with the same training material, horizon, and constraints,
497
- use `prepare_continuation_plan(...)` once and bind each prefix later:
498
-
499
- ```python
500
- plan = prepare_continuation_plan(
501
- [training_events],
502
- horizon=8,
503
- max_order=4,
504
- constraints=constraints,
505
- event_to_symbol=lambda event: (event.pitch, event.duration),
506
- )
507
-
508
- backend = plan.for_prefix(prefix_events)
509
- generated = backend.sample_events_with_orders(rng=0)
510
- ```
511
-
512
- For variable musical length in a fixed BP horizon, use
513
- `padded_duration_total_constraint(...)` with explicit zero-duration PAD symbols
514
- added to phrase/bar training sequences via `append_padding(...)`. See
515
- `examples/padded_duration_order_stack_backend.py`.
516
-
517
- ## Brute Force and Metrics
518
-
519
- For small examples, the package includes exact enumeration helpers:
520
-
521
- - `brute_force_distribution(...)`
522
- - `brute_force_partition_function(...)`
523
- - `conditional_distribution(...)`
524
-
525
- For empirical checks:
526
-
527
- - `empirical_distribution(...)`
528
- - `total_variation(...)`
529
-
530
- These are intended for tests, toy examples, and exactness validation.
531
-
532
- ## Paper Experiments
533
-
534
- The reusable library lives in `vo_regular_bp/`. Paper-specific validation and
535
- benchmark implementations are kept under `paper/variable_order_regular_bp/`.
536
- The top-level `scripts/` files remain as compatibility launchers, so existing
537
- commands still work:
538
-
539
- ```bash
540
- python scripts/eval_tiny_exactness.py
541
- python scripts/eval_scalability.py --help
542
- python scripts/eval_bach_scalability.py --help
543
- python scripts/eval_neurips_ablation.py --help
544
- python scripts/eval_virtual_augmentation.py --help
545
- python scripts/eval_bach_continuator_compare.py --help
546
- python scripts/eval_bach_positional_direct.py --help
547
- ```
548
-
549
- The Bach data lives in `data/bach_prelude_c_major_pitches.txt`.
550
-
551
- Rendered experiment notes are in `reports/`, including:
552
-
553
- - `reports/evaluation_report.md`
554
- - `reports/optimization_report.md`
555
- - `reports/bach_contextbp_final_compare.md`
556
- - `reports/bach_policy_backoff_paper_results.md`
557
-
558
- ## Semantics Note
559
-
560
- The direct product-BP path (`ContextGraph` plus `run_bp`) samples exactly from a
561
- single fixed stochastic source conditioned on a regular language:
562
-
563
- ```text
564
- P_source(x | x in L(A))
565
- ```
566
-
567
- The order-stack path is exact for a different object: a constrained generation
568
- policy that prefers higher orders when they have positive feasible future mass
569
- and backs off only when needed. Its `success_mass` is therefore a policy success
570
- indicator/mass, not the partition function of one fixed source graph.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/pyproject.toml DELETED
@@ -1,51 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=68"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "vo-regular-bp"
7
- version = "0.1.1"
8
- description = "Exact constrained sampling from sparse variable-order context models under regular constraints."
9
- readme = "README.md"
10
- requires-python = ">=3.10"
11
- license = { file = "LICENSE" }
12
- authors = [
13
- { name = "Francois Pachet" }
14
- ]
15
- dependencies = []
16
- keywords = [
17
- "constrained-sampling",
18
- "variable-order-markov",
19
- "belief-propagation",
20
- "regular-constraints",
21
- "music-generation",
22
- ]
23
- classifiers = [
24
- "Development Status :: 3 - Alpha",
25
- "Intended Audience :: Developers",
26
- "Intended Audience :: Science/Research",
27
- "License :: OSI Approved :: MIT License",
28
- "Programming Language :: Python :: 3",
29
- "Programming Language :: Python :: 3 :: Only",
30
- "Programming Language :: Python :: 3.10",
31
- "Programming Language :: Python :: 3.11",
32
- "Programming Language :: Python :: 3.12",
33
- "Programming Language :: Python :: 3.13",
34
- "Topic :: Scientific/Engineering",
35
- "Topic :: Software Development :: Libraries :: Python Modules",
36
- ]
37
-
38
- [project.optional-dependencies]
39
- test = [
40
- "pytest>=8",
41
- ]
42
-
43
- [project.urls]
44
- Repository = "https://github.com/fpachet/vo-regular-bp"
45
-
46
- [tool.setuptools.packages.find]
47
- include = ["vo_regular_bp*"]
48
-
49
- [tool.pytest.ini_options]
50
- testpaths = ["tests"]
51
- pythonpath = ["."]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/__init__.py DELETED
@@ -1,256 +0,0 @@
1
- """Exact constrained sampling for sparse variable-order context models.
2
-
3
- The top-level package exports the stable library surface: generic product BP,
4
- Continuator-style order-stack backends, event adapters, constraint builders, and
5
- diagnostic helpers. Lower-level modules remain importable for experiments, but
6
- external projects should prefer the names exported here.
7
- """
8
-
9
- from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
10
- from importlib.metadata import version as _metadata_version
11
-
12
- try:
13
- __version__ = _metadata_version("vo-regular-bp")
14
- except _PackageNotFoundError:
15
- __version__ = "0+unknown"
16
-
17
- from .acceptors import (
18
- DFA,
19
- DenseForbiddenSubstringDFA,
20
- SupportsWeightedTransitions,
21
- WeightedDFA,
22
- all_of,
23
- cumulative_meter_acceptor,
24
- dense_forbidden_substring_acceptor,
25
- forbidden_substring_acceptor,
26
- max_order_acceptor,
27
- meter_acceptor,
28
- positional_acceptor,
29
- true_acceptor,
30
- )
31
- from .adapters import (
32
- EventCodec,
33
- EventOrderStackBackend,
34
- EventOrderStackPlan,
35
- GeneratedEvents,
36
- infer_symbol_to_event,
37
- prepare_constrained_order_stack_from_events,
38
- prepare_constrained_order_stack_plan_from_events,
39
- )
40
- from .augmentation import (
41
- SymbolTransform,
42
- VirtualAugmentedOrderStackModel,
43
- integer_shift_transform,
44
- integer_shift_transforms,
45
- materialize_transformed_sequences,
46
- )
47
- from .brute_force import (
48
- brute_force_distribution,
49
- brute_force_partition_function,
50
- conditional_distribution,
51
- )
52
- from .backend import (
53
- BackendDiagnostics,
54
- ConstrainedOrderStackBackend,
55
- ConstrainedOrderStackPlan,
56
- ConstrainedOrderStackSupportPlan,
57
- GeneratedSequence,
58
- UntilLengthDiagnostics,
59
- UntilOrderStackBackend,
60
- UntilOrderStackDiagnostics,
61
- prepare_constrained_order_stack,
62
- prepare_constrained_order_stack_plan,
63
- prepare_constrained_order_stack_plan_from_sequences,
64
- prepare_constrained_order_stack_support_plan,
65
- prepare_constrained_order_stack_support_plan_from_sequences,
66
- prepare_constrained_order_stack_from_sequences,
67
- prepare_until_end_order_stack,
68
- prepare_until_order_stack,
69
- run_constrained_order_stack,
70
- )
71
- from .constraint_builders import (
72
- at_position,
73
- avoid_copied_ngrams,
74
- combine_constraints,
75
- cumulative_meter,
76
- final_pitch_class,
77
- final_symbol,
78
- final_symbols,
79
- meter_pattern,
80
- padded_duration_total,
81
- )
82
- from .continuator import (
83
- duration_total_constraint,
84
- final_pitch_class_constraint,
85
- meter_cycle_constraint,
86
- padded_duration_total_constraint,
87
- prepare_continuation_backend,
88
- prepare_continuation_plan,
89
- )
90
- from .constraints import (
91
- CompiledConstraints,
92
- ConstraintSet,
93
- CumulativeMeterConstraint,
94
- MeterConstraint,
95
- compile_constraints,
96
- )
97
- from .context import Context, ContextGraph, Edge, Symbol
98
- from .metrics import empirical_distribution, total_variation
99
- from .minimization import (
100
- ExactQuotientStats,
101
- exact_context_graph_quotient_stats,
102
- exact_fixed_order_graph_quotient_stats,
103
- minimize_context_graph,
104
- minimize_fixed_order_graph,
105
- minimize_fixed_order_graphs,
106
- )
107
- from .order_stack_bp import (
108
- DurationViewQuotientDiagnostics,
109
- DurationViewQuotientOrderStats,
110
- LongestFeasiblePolicy,
111
- OrderStackBPPlan,
112
- OrderStackBPResult,
113
- OrderStackModel,
114
- RegularOrderStackBPPlan,
115
- RegularOrderStackBPResult,
116
- SingletonAvoidingBackoffPolicy,
117
- padded_melody_duration_view_quotient_diagnostics,
118
- prepare_order_stack_bp,
119
- prepare_order_stack_dfa_bp,
120
- prepare_order_stack_masked_dfa_bp,
121
- run_order_stack_dfa_bp,
122
- run_order_stack_masked_dfa_bp,
123
- run_order_stack_bp,
124
- )
125
- from .orbit_diagnostics import (
126
- ForbiddenPatternOrbitStats,
127
- RegularProductOrbitStats,
128
- RegularRowSignatureStats,
129
- canonical_integer_shift_key,
130
- forbidden_pattern_orbit_stats,
131
- regular_product_orbit_stats,
132
- regular_row_signature_stats,
133
- )
134
- from .padding import append_padding
135
- from .positional_bp import LazyBackoffContextModel, PositionalBPResult, run_positional_bp
136
- from .product_bp import ProductBPResult, run_bp, sample_exact
137
-
138
- __all__ = [
139
- "__version__",
140
- # Core symbolic graph and DFA API.
141
- "Context",
142
- "ContextGraph",
143
- "DFA",
144
- "DenseForbiddenSubstringDFA",
145
- "Edge",
146
- "SupportsWeightedTransitions",
147
- "Symbol",
148
- "WeightedDFA",
149
- "all_of",
150
- "cumulative_meter_acceptor",
151
- "dense_forbidden_substring_acceptor",
152
- "forbidden_substring_acceptor",
153
- "max_order_acceptor",
154
- "meter_acceptor",
155
- "positional_acceptor",
156
- "true_acceptor",
157
- # General exact product BP.
158
- "LazyBackoffContextModel",
159
- "PositionalBPResult",
160
- "ProductBPResult",
161
- "run_bp",
162
- "run_positional_bp",
163
- "sample_exact",
164
- # Library-facing order-stack backend.
165
- "BackendDiagnostics",
166
- "ConstrainedOrderStackBackend",
167
- "ConstrainedOrderStackPlan",
168
- "ConstrainedOrderStackSupportPlan",
169
- "DurationViewQuotientDiagnostics",
170
- "DurationViewQuotientOrderStats",
171
- "GeneratedSequence",
172
- "LongestFeasiblePolicy",
173
- "OrderStackBPPlan",
174
- "OrderStackBPResult",
175
- "OrderStackModel",
176
- "RegularOrderStackBPPlan",
177
- "RegularOrderStackBPResult",
178
- "SingletonAvoidingBackoffPolicy",
179
- "UntilLengthDiagnostics",
180
- "UntilOrderStackBackend",
181
- "UntilOrderStackDiagnostics",
182
- "prepare_constrained_order_stack",
183
- "prepare_constrained_order_stack_plan",
184
- "prepare_constrained_order_stack_plan_from_sequences",
185
- "prepare_constrained_order_stack_support_plan",
186
- "prepare_constrained_order_stack_support_plan_from_sequences",
187
- "prepare_constrained_order_stack_from_sequences",
188
- "prepare_order_stack_bp",
189
- "prepare_order_stack_dfa_bp",
190
- "prepare_order_stack_masked_dfa_bp",
191
- "padded_melody_duration_view_quotient_diagnostics",
192
- "prepare_until_end_order_stack",
193
- "prepare_until_order_stack",
194
- "run_constrained_order_stack",
195
- "run_order_stack_bp",
196
- "run_order_stack_dfa_bp",
197
- "run_order_stack_masked_dfa_bp",
198
- # Exact source graph minimization.
199
- "ExactQuotientStats",
200
- "exact_context_graph_quotient_stats",
201
- "exact_fixed_order_graph_quotient_stats",
202
- "minimize_context_graph",
203
- "minimize_fixed_order_graph",
204
- "minimize_fixed_order_graphs",
205
- # Transformation-orbit diagnostics.
206
- "ForbiddenPatternOrbitStats",
207
- "RegularProductOrbitStats",
208
- "RegularRowSignatureStats",
209
- "canonical_integer_shift_key",
210
- "forbidden_pattern_orbit_stats",
211
- "regular_product_orbit_stats",
212
- "regular_row_signature_stats",
213
- # Virtual data augmentation.
214
- "SymbolTransform",
215
- "VirtualAugmentedOrderStackModel",
216
- "integer_shift_transform",
217
- "integer_shift_transforms",
218
- "materialize_transformed_sequences",
219
- # Fixed-horizon padding helpers.
220
- "append_padding",
221
- # Constraint specifications and builders.
222
- "CompiledConstraints",
223
- "ConstraintSet",
224
- "CumulativeMeterConstraint",
225
- "MeterConstraint",
226
- "at_position",
227
- "avoid_copied_ngrams",
228
- "combine_constraints",
229
- "compile_constraints",
230
- "cumulative_meter",
231
- "final_pitch_class",
232
- "final_symbol",
233
- "final_symbols",
234
- "meter_pattern",
235
- "padded_duration_total",
236
- # Event and Continuator-style adapters.
237
- "EventCodec",
238
- "EventOrderStackBackend",
239
- "EventOrderStackPlan",
240
- "GeneratedEvents",
241
- "duration_total_constraint",
242
- "final_pitch_class_constraint",
243
- "infer_symbol_to_event",
244
- "meter_cycle_constraint",
245
- "padded_duration_total_constraint",
246
- "prepare_constrained_order_stack_from_events",
247
- "prepare_constrained_order_stack_plan_from_events",
248
- "prepare_continuation_backend",
249
- "prepare_continuation_plan",
250
- # Analysis/test helpers that are useful for exactness checks.
251
- "brute_force_distribution",
252
- "brute_force_partition_function",
253
- "conditional_distribution",
254
- "empirical_distribution",
255
- "total_variation",
256
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/acceptors.py DELETED
@@ -1,660 +0,0 @@
1
- """Deterministic regular acceptors used by product BP."""
2
-
3
- from __future__ import annotations
4
-
5
- import math
6
- from itertools import product
7
- from typing import Callable, Hashable, Iterable, Mapping, Protocol, Sequence
8
-
9
- Symbol = Hashable
10
- State = Hashable
11
-
12
-
13
- class SupportsWeightedTransitions(Protocol):
14
- """Structural acceptor interface with optional soft transition weights."""
15
-
16
- start_state: State
17
-
18
- def next_state(self, state: State, symbol: Symbol) -> State | None:
19
- ...
20
-
21
- def is_accepting(self, state: State) -> bool:
22
- ...
23
-
24
- def transition_weight(self, state: State, symbol: Symbol) -> float:
25
- ...
26
-
27
-
28
- class DFA:
29
- """Small deterministic acceptor interface.
30
-
31
- A transition returning ``None`` means that the emitted symbol is rejected
32
- from that state. Legal transitions can optionally carry nonnegative
33
- multiplicative weights through ``transition_weight``; the default is
34
- ``1.0``, so ordinary hard DFAs are unchanged.
35
- """
36
-
37
- def __init__(
38
- self,
39
- *,
40
- start_state: State,
41
- accept_states: Iterable[State] | None = None,
42
- transitions: Mapping[State, Mapping[Symbol, State]] | None = None,
43
- states: Iterable[State] | None = None,
44
- alphabet: Iterable[Symbol] | None = None,
45
- transition_func: Callable[[State, Symbol], State | None] | None = None,
46
- transition_weights: Mapping[State, Mapping[Symbol, float]] | None = None,
47
- transition_weight_func: Callable[[State, Symbol], float] | None = None,
48
- accept_func: Callable[[State], bool] | None = None,
49
- name: str = "dfa",
50
- ) -> None:
51
- if accept_states is None and accept_func is None:
52
- raise ValueError("either accept_states or accept_func is required")
53
- if transitions is None and transition_func is None:
54
- raise ValueError("either transitions or transition_func is required")
55
-
56
- self.start_state = start_state
57
- self.accept_states = frozenset(accept_states or ())
58
- self.transitions = {
59
- state: dict(symbols)
60
- for state, symbols in (transitions or {}).items()
61
- }
62
- self._transition_weights = {
63
- state: dict(symbols)
64
- for state, symbols in (transition_weights or {}).items()
65
- }
66
- self.states = None if states is None else frozenset(states)
67
- self.alphabet = None if alphabet is None else frozenset(alphabet)
68
- self._transition_func = transition_func
69
- self._transition_weight_func = transition_weight_func
70
- self._accept_func = accept_func
71
- self.name = name
72
-
73
- def next_state(self, state: State, symbol: Symbol) -> State | None:
74
- if self._transition_func is not None:
75
- return self._transition_func(state, symbol)
76
- return self.transitions.get(state, {}).get(symbol)
77
-
78
- def transition_weight(self, state: State, symbol: Symbol) -> float:
79
- transition_weight_func = getattr(self, "_transition_weight_func", None)
80
- if transition_weight_func is not None:
81
- return float(transition_weight_func(state, symbol))
82
- transition_weights = getattr(self, "_transition_weights", {})
83
- return float(transition_weights.get(state, {}).get(symbol, 1.0))
84
-
85
- def is_accepting(self, state: State) -> bool:
86
- if self._accept_func is not None:
87
- return bool(self._accept_func(state))
88
- return state in self.accept_states
89
-
90
- def accepts(self, sequence: Sequence[Symbol]) -> bool:
91
- state = self.start_state
92
- for symbol in sequence:
93
- current_state = state
94
- state = self.next_state(state, symbol)
95
- if state is None:
96
- return False
97
- if transition_weight(self, current_state, symbol) <= 0.0:
98
- return False
99
- return self.is_accepting(state)
100
-
101
- def state_count(self) -> int | None:
102
- if self.states is None:
103
- return None
104
- return len(self.states)
105
-
106
-
107
- class WeightedDFA(DFA):
108
- """DFA subclass documenting weighted regular-constraint semantics.
109
-
110
- Subclasses may override ``transition_weight``. The inherited implementation
111
- supports either a transition-weight mapping or a callable passed to
112
- ``DFA.__init__``.
113
- """
114
-
115
-
116
- class DenseForbiddenSubstringDFA(DFA):
117
- """Finite-alphabet forbidden-substring DFA with dense integer states.
118
-
119
- States are integer ids for the same proper-prefix states used by
120
- :func:`forbidden_substring_acceptor`. A transition value of ``-1`` means
121
- rejection. The public methods mirror :class:`DFA`, while
122
- ``dense_transition_by_symbol`` gives hot loops a direct table lookup path.
123
- """
124
-
125
- rejected_state = -1
126
-
127
- def __init__(
128
- self,
129
- *,
130
- prefixes: Sequence[tuple[Symbol, ...]],
131
- alphabet: Iterable[Symbol],
132
- transition_table: Sequence[Sequence[int]],
133
- name: str = "dense_forbidden_substring",
134
- ) -> None:
135
- alphabet_tuple = tuple(alphabet)
136
- self.start_state = 0
137
- self.prefixes = tuple(prefixes)
138
- self.states = frozenset(range(len(self.prefixes)))
139
- self.accept_states = self.states
140
- self.alphabet = frozenset(alphabet_tuple)
141
- self.transition_table = tuple(tuple(int(next_state) for next_state in row) for row in transition_table)
142
- self.symbol_to_index = {symbol: index for index, symbol in enumerate(alphabet_tuple)}
143
- self.dense_transition_by_symbol = {
144
- symbol: tuple(row[index] for row in self.transition_table)
145
- for symbol, index in self.symbol_to_index.items()
146
- }
147
- self.name = name
148
-
149
- def next_state(self, state: State, symbol: Symbol) -> State | None:
150
- if not isinstance(state, int):
151
- raise TypeError("dense forbidden-substring states must be integers")
152
- transitions = self.dense_transition_by_symbol.get(symbol)
153
- if transitions is None:
154
- return None
155
- next_state = transitions[state]
156
- if next_state == self.rejected_state:
157
- return None
158
- return next_state
159
-
160
- def is_accepting(self, state: State) -> bool:
161
- return isinstance(state, int) and 0 <= state < len(self.prefixes)
162
-
163
- def accepts(self, sequence: Sequence[Symbol]) -> bool:
164
- state = self.start_state
165
- for symbol in sequence:
166
- next_state = self.next_state(state, symbol)
167
- if next_state is None:
168
- return False
169
- if transition_weight(self, state, symbol) <= 0.0:
170
- return False
171
- state = next_state
172
- return self.is_accepting(state)
173
-
174
- def state_count(self) -> int:
175
- return len(self.prefixes)
176
-
177
- def edge_count(self) -> int:
178
- return sum(
179
- 1
180
- for row in self.transition_table
181
- for next_state in row
182
- if next_state != self.rejected_state
183
- )
184
-
185
-
186
- def true_acceptor(*, name: str = "true") -> DFA:
187
- """Accept every finite sequence over any alphabet."""
188
-
189
- return DFA(
190
- start_state=0,
191
- accept_states={0},
192
- states={0},
193
- transition_func=lambda state, symbol: 0,
194
- name=name,
195
- )
196
-
197
-
198
- def all_of(*acceptors: DFA, name: str = "all_of") -> DFA:
199
- """Intersection of deterministic acceptors."""
200
-
201
- if not acceptors:
202
- return true_acceptor(name=name)
203
-
204
- start = tuple(acceptor.start_state for acceptor in acceptors)
205
-
206
- def transition(state: State, symbol: Symbol) -> State | None:
207
- next_parts = []
208
- for acceptor, part in zip(acceptors, state):
209
- next_part = acceptor.next_state(part, symbol)
210
- if next_part is None:
211
- return None
212
- next_parts.append(next_part)
213
- return tuple(next_parts)
214
-
215
- def accepting(state: State) -> bool:
216
- return all(acceptor.is_accepting(part) for acceptor, part in zip(acceptors, state))
217
-
218
- def weight(state: State, symbol: Symbol) -> float:
219
- total = 1.0
220
- for acceptor, part in zip(acceptors, state):
221
- total *= transition_weight(acceptor, part, symbol)
222
- return total
223
-
224
- states = None
225
- if all(acceptor.states is not None for acceptor in acceptors):
226
- sizes = [len(acceptor.states or ()) for acceptor in acceptors]
227
- product_size = 1
228
- for size in sizes:
229
- product_size *= size
230
- if product_size <= 100_000:
231
- state_sets = [acceptor.states or () for acceptor in acceptors]
232
- states = set(product(*state_sets))
233
-
234
- alphabets = [acceptor.alphabet for acceptor in acceptors if acceptor.alphabet is not None]
235
- alphabet = set.intersection(*map(set, alphabets)) if alphabets else None
236
-
237
- dfa = DFA(
238
- start_state=start,
239
- states=states,
240
- alphabet=alphabet,
241
- transition_func=transition,
242
- transition_weight_func=weight,
243
- accept_func=accepting,
244
- name=name,
245
- )
246
- dfa.component_acceptors = tuple(acceptors) # type: ignore[attr-defined]
247
- return dfa
248
-
249
-
250
- def transition_weight(acceptor: object, state: State, symbol: Symbol) -> float:
251
- """Return a validated multiplicative weight for a legal transition.
252
-
253
- Acceptors without ``transition_weight`` are treated as ordinary hard DFAs
254
- with weight ``1.0``. A weight of ``0.0`` is allowed and acts as hard
255
- rejection in BP; negative or non-finite weights are invalid.
256
- """
257
-
258
- method = getattr(acceptor, "transition_weight", None)
259
- raw_weight = 1.0 if method is None else method(state, symbol)
260
- weight = float(raw_weight)
261
- if not math.isfinite(weight) or weight < 0.0:
262
- raise ValueError(
263
- f"transition weight for state {state!r} and symbol {symbol!r} "
264
- f"must be a finite nonnegative number, got {raw_weight!r}"
265
- )
266
- return weight
267
-
268
-
269
- def has_custom_transition_weights(acceptor: object) -> bool:
270
- method = getattr(type(acceptor), "transition_weight", None)
271
- default_method = getattr(DFA, "transition_weight")
272
- if method is not None and method is not default_method:
273
- return True
274
- return bool(
275
- getattr(acceptor, "_transition_weights", None)
276
- or getattr(acceptor, "_transition_weight_func", None) is not None
277
- )
278
-
279
-
280
- def positional_acceptor(
281
- length: int,
282
- constraints: Mapping[int, Iterable[Symbol] | Symbol] | None = None,
283
- *,
284
- alphabet: Iterable[Symbol] | None = None,
285
- name: str = "positional",
286
- ) -> DFA:
287
- """Accept length-``n`` strings satisfying per-position allowed sets.
288
-
289
- Positions are zero-based. Constraint values may be a single symbol or an
290
- iterable of allowed symbols.
291
- """
292
-
293
- if length < 0:
294
- raise ValueError("length must be non-negative")
295
-
296
- allowed_by_pos: dict[int, set[Symbol]] = {}
297
- for position, allowed in (constraints or {}).items():
298
- if position < 0 or position >= length:
299
- raise ValueError(f"position {position} is outside length {length}")
300
- if isinstance(allowed, (str, bytes)):
301
- allowed_by_pos[position] = {allowed}
302
- else:
303
- try:
304
- allowed_by_pos[position] = set(allowed) # type: ignore[arg-type]
305
- except TypeError:
306
- allowed_by_pos[position] = {allowed} # type: ignore[list-item]
307
-
308
- def transition(position: State, symbol: Symbol) -> State | None:
309
- if not isinstance(position, int) or position >= length:
310
- return None
311
- allowed = allowed_by_pos.get(position)
312
- if allowed is not None and symbol not in allowed:
313
- return None
314
- return position + 1
315
-
316
- return DFA(
317
- start_state=0,
318
- accept_states={length},
319
- states=set(range(length + 1)),
320
- alphabet=alphabet,
321
- transition_func=transition,
322
- name=name,
323
- )
324
-
325
-
326
- def meter_acceptor(
327
- pattern: Sequence[Hashable | Iterable[Hashable] | None],
328
- symbol_to_meter: Mapping[Symbol, Hashable] | Callable[[Symbol], Hashable],
329
- *,
330
- alphabet: Iterable[Symbol] | None = None,
331
- name: str = "meter",
332
- ) -> DFA:
333
- """Accept strings whose per-symbol meter classes match ``pattern``.
334
-
335
- Each pattern entry may be a single class, an iterable of allowed classes, or
336
- ``None`` as a wildcard. This intentionally keeps meter generic: callers can
337
- map symbols to stress, duration, pitch class, or any other finite class.
338
- """
339
-
340
- allowed_by_pos: list[set[Hashable] | None] = []
341
- for expected in pattern:
342
- if expected is None:
343
- allowed_by_pos.append(None)
344
- elif isinstance(expected, (str, bytes)):
345
- allowed_by_pos.append({expected})
346
- else:
347
- try:
348
- allowed_by_pos.append(set(expected)) # type: ignore[arg-type]
349
- except TypeError:
350
- allowed_by_pos.append({expected}) # type: ignore[list-item]
351
-
352
- if callable(symbol_to_meter):
353
- meter_of = symbol_to_meter
354
- else:
355
- meter_map = dict(symbol_to_meter)
356
- meter_of = lambda symbol: meter_map[symbol]
357
-
358
- length = len(pattern)
359
-
360
- def transition(position: State, symbol: Symbol) -> State | None:
361
- if not isinstance(position, int) or position >= length:
362
- return None
363
- allowed = allowed_by_pos[position]
364
- if allowed is not None and meter_of(symbol) not in allowed:
365
- return None
366
- return position + 1
367
-
368
- return DFA(
369
- start_state=0,
370
- accept_states={length},
371
- states=set(range(length + 1)),
372
- alphabet=alphabet,
373
- transition_func=transition,
374
- name=name,
375
- )
376
-
377
-
378
- def cumulative_meter_acceptor(
379
- length: int,
380
- cost: Mapping[Symbol, int] | Callable[[Symbol], int],
381
- predicate: Callable[[int, Symbol, int], bool] | None = None,
382
- *,
383
- alphabet: Iterable[Symbol] | None = None,
384
- max_cost: int | None = None,
385
- accept_costs: Iterable[int] | Callable[[int], bool] | None = None,
386
- end_symbol: Symbol | None = None,
387
- name: str = "cumulative_meter",
388
- ) -> DFA:
389
- """Accept strings satisfying a cumulative meter predicate.
390
-
391
- This is the regular-constraint form of the paper-style meter predicate
392
- ``pi(c, x, k)``: before emitting symbol ``x`` at 1-based position ``k``,
393
- the DFA state stores the cumulative cost ``c`` of previous symbols. The
394
- transition is accepted iff ``predicate(c, x, k)`` holds, then the symbol
395
- cost is added to the state.
396
-
397
- ``accept_costs`` can be used for final total-cost requirements. If
398
- ``end_symbol`` is provided, once it appears, all later symbols must be the
399
- same padding symbol. Passing ``max_cost`` bounds transitions and lets the
400
- DFA expose an explicit finite state set.
401
- """
402
-
403
- if length < 0:
404
- raise ValueError("length must be non-negative")
405
- if max_cost is not None and max_cost < 0:
406
- raise ValueError("max_cost must be non-negative")
407
-
408
- if callable(cost):
409
- cost_of = cost
410
- else:
411
- cost_map = dict(cost)
412
-
413
- def cost_of(symbol: Symbol) -> int:
414
- return cost_map[symbol]
415
-
416
- if accept_costs is None:
417
- accepts_cost = lambda total: True
418
- elif callable(accept_costs):
419
- accepts_cost = accept_costs
420
- else:
421
- accepted_totals = frozenset(int(total) for total in accept_costs)
422
- accepts_cost = lambda total: total in accepted_totals
423
-
424
- def transition(state: State, symbol: Symbol) -> State | None:
425
- if not (
426
- isinstance(state, tuple)
427
- and len(state) == 3
428
- and isinstance(state[0], int)
429
- and isinstance(state[1], int)
430
- and isinstance(state[2], bool)
431
- ):
432
- raise TypeError("cumulative-meter states must be (position, total_cost, ended)")
433
-
434
- position, total_cost, ended = state
435
- if position >= length:
436
- return None
437
- if ended and symbol != end_symbol:
438
- return None
439
-
440
- symbol_cost = _nonnegative_int_cost(cost_of(symbol), symbol)
441
- next_position = position + 1
442
- next_total = total_cost + symbol_cost
443
- if max_cost is not None and next_total > max_cost:
444
- return None
445
- if predicate is not None and not predicate(total_cost, symbol, next_position):
446
- return None
447
-
448
- next_ended = ended or (end_symbol is not None and symbol == end_symbol)
449
- return (next_position, next_total, next_ended)
450
-
451
- def accepting(state: State) -> bool:
452
- return (
453
- isinstance(state, tuple)
454
- and len(state) == 3
455
- and state[0] == length
456
- and isinstance(state[1], int)
457
- and bool(accepts_cost(state[1]))
458
- )
459
-
460
- states = None
461
- if max_cost is not None:
462
- ended_values = (False, True) if end_symbol is not None else (False,)
463
- states = {
464
- (position, total, ended)
465
- for position in range(length + 1)
466
- for total in range(max_cost + 1)
467
- for ended in ended_values
468
- }
469
-
470
- return DFA(
471
- start_state=(0, 0, False),
472
- states=states,
473
- alphabet=alphabet,
474
- transition_func=transition,
475
- accept_func=accepting,
476
- name=name,
477
- )
478
-
479
-
480
- def forbidden_substring_acceptor(
481
- forbidden_patterns: Iterable[Sequence[Symbol]],
482
- *,
483
- alphabet: Iterable[Symbol] | None = None,
484
- name: str = "forbidden_substring",
485
- ) -> DFA:
486
- """Accept strings that contain none of the forbidden substrings.
487
-
488
- The states are Aho-Corasick-style prefixes: the current state stores the
489
- longest suffix of the generated sequence that is a proper prefix of a
490
- forbidden pattern. Completing a forbidden pattern rejects the transition.
491
- """
492
-
493
- patterns = tuple(tuple(pattern) for pattern in forbidden_patterns)
494
- if any(len(pattern) == 0 for pattern in patterns):
495
- raise ValueError("empty forbidden patterns would reject every sequence")
496
- if not patterns:
497
- return true_acceptor(name=name)
498
-
499
- prefixes: set[tuple[Symbol, ...]] = {()}
500
- for pattern in patterns:
501
- for prefix_len in range(1, len(pattern)):
502
- prefixes.add(pattern[:prefix_len])
503
-
504
- max_prefix_len = max((len(prefix) for prefix in prefixes), default=0)
505
- patterns_by_len: dict[int, set[tuple[Symbol, ...]]] = {}
506
- for pattern in patterns:
507
- patterns_by_len.setdefault(len(pattern), set()).add(pattern)
508
-
509
- if len(patterns_by_len) == 1:
510
- forbidden_len, forbidden_set = next(iter(patterns_by_len.items()))
511
-
512
- def completes_forbidden(candidate: tuple[Symbol, ...]) -> bool:
513
- return len(candidate) >= forbidden_len and candidate[-forbidden_len:] in forbidden_set
514
-
515
- else:
516
-
517
- def completes_forbidden(candidate: tuple[Symbol, ...]) -> bool:
518
- return any(
519
- len(candidate) >= pattern_len and candidate[-pattern_len:] in pattern_set
520
- for pattern_len, pattern_set in patterns_by_len.items()
521
- )
522
-
523
- def next_prefix(state: State, symbol: Symbol) -> State | None:
524
- if not isinstance(state, tuple):
525
- raise TypeError("forbidden-substring states must be tuples")
526
- candidate = state + (symbol,)
527
- if completes_forbidden(candidate):
528
- return None
529
- limit = min(len(candidate), max_prefix_len)
530
- for size in range(limit, -1, -1):
531
- suffix = candidate[-size:] if size else ()
532
- if suffix in prefixes:
533
- return suffix
534
- return ()
535
-
536
- transitions = None
537
- if alphabet is not None:
538
- alphabet_set = frozenset(alphabet)
539
- transitions = {
540
- state: {
541
- symbol: next_state
542
- for symbol in alphabet_set
543
- if (next_state := next_prefix(state, symbol)) is not None
544
- }
545
- for state in prefixes
546
- }
547
- else:
548
- alphabet_set = None
549
-
550
- return DFA(
551
- start_state=(),
552
- accept_states=prefixes,
553
- transitions=transitions,
554
- states=prefixes,
555
- alphabet=alphabet_set,
556
- transition_func=None if transitions is not None else next_prefix,
557
- name=name,
558
- )
559
-
560
-
561
- def dense_forbidden_substring_acceptor(
562
- forbidden_patterns: Iterable[Sequence[Symbol]],
563
- *,
564
- alphabet: Iterable[Symbol],
565
- name: str = "dense_forbidden_substring",
566
- ) -> DenseForbiddenSubstringDFA:
567
- """Dense finite-alphabet acceptor for strings with no forbidden substring."""
568
-
569
- patterns = tuple(tuple(pattern) for pattern in forbidden_patterns)
570
- if any(len(pattern) == 0 for pattern in patterns):
571
- raise ValueError("empty forbidden patterns would reject every sequence")
572
-
573
- alphabet_tuple = tuple(alphabet)
574
- prefix_to_id: dict[tuple[Symbol, ...], int] = {(): 0}
575
- prefixes: list[tuple[Symbol, ...]] = [()]
576
- for pattern in patterns:
577
- for prefix_len in range(1, len(pattern)):
578
- prefix = pattern[:prefix_len]
579
- if prefix not in prefix_to_id:
580
- prefix_to_id[prefix] = len(prefixes)
581
- prefixes.append(prefix)
582
-
583
- max_prefix_len = max((len(prefix) for prefix in prefixes), default=0)
584
- patterns_by_len: dict[int, set[tuple[Symbol, ...]]] = {}
585
- for pattern in patterns:
586
- patterns_by_len.setdefault(len(pattern), set()).add(pattern)
587
-
588
- if len(patterns_by_len) == 1:
589
- forbidden_len, forbidden_set = next(iter(patterns_by_len.items()))
590
-
591
- def completes_forbidden(candidate: tuple[Symbol, ...]) -> bool:
592
- return len(candidate) >= forbidden_len and candidate[-forbidden_len:] in forbidden_set
593
-
594
- else:
595
-
596
- def completes_forbidden(candidate: tuple[Symbol, ...]) -> bool:
597
- return any(
598
- len(candidate) >= pattern_len and candidate[-pattern_len:] in pattern_set
599
- for pattern_len, pattern_set in patterns_by_len.items()
600
- )
601
-
602
- def next_prefix(state: tuple[Symbol, ...], symbol: Symbol) -> tuple[Symbol, ...] | None:
603
- candidate = state + (symbol,)
604
- if completes_forbidden(candidate):
605
- return None
606
- limit = min(len(candidate), max_prefix_len)
607
- for size in range(limit, -1, -1):
608
- suffix = candidate[-size:] if size else ()
609
- if suffix in prefix_to_id:
610
- return suffix
611
- return ()
612
-
613
- transition_table: list[list[int]] = []
614
- for prefix in prefixes:
615
- row: list[int] = []
616
- for symbol in alphabet_tuple:
617
- next_state = next_prefix(prefix, symbol)
618
- row.append(DenseForbiddenSubstringDFA.rejected_state if next_state is None else prefix_to_id[next_state])
619
- transition_table.append(row)
620
-
621
- return DenseForbiddenSubstringDFA(
622
- prefixes=prefixes,
623
- alphabet=alphabet_tuple,
624
- transition_table=transition_table,
625
- name=name,
626
- )
627
-
628
-
629
- def max_order_acceptor(
630
- reference_sequences: Iterable[Sequence[Symbol]],
631
- max_order: int,
632
- *,
633
- alphabet: Iterable[Symbol] | None = None,
634
- name: str = "max_order",
635
- ) -> DFA:
636
- """Forbid every reference substring of length ``max_order + 1``.
637
-
638
- This is the standard regular way to enforce "do not copy beyond max order":
639
- any generated window longer than ``max_order`` that appears in the reference
640
- material is rejected.
641
- """
642
-
643
- if max_order < 0:
644
- raise ValueError("max_order must be non-negative")
645
-
646
- window = max_order + 1
647
- forbidden: set[tuple[Symbol, ...]] = set()
648
- for sequence in reference_sequences:
649
- tokens = tuple(sequence)
650
- for index in range(0, len(tokens) - window + 1):
651
- forbidden.add(tokens[index : index + window])
652
- return forbidden_substring_acceptor(forbidden, alphabet=alphabet, name=name)
653
-
654
-
655
- def _nonnegative_int_cost(value: int, symbol: Symbol) -> int:
656
- if not isinstance(value, int):
657
- raise TypeError(f"cost for symbol {symbol!r} must be an integer")
658
- if value < 0:
659
- raise ValueError(f"cost for symbol {symbol!r} must be non-negative")
660
- return value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/adapters.py DELETED
@@ -1,270 +0,0 @@
1
- """Adapters for projects that use rich event objects instead of symbols."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable, Iterable, Mapping, Sequence
6
- from dataclasses import dataclass
7
- import random
8
- from typing import Generic, TypeVar
9
-
10
- from .backend import (
11
- BackendDiagnostics,
12
- ConstrainedOrderStackBackend,
13
- ConstrainedOrderStackPlan,
14
- prepare_constrained_order_stack_plan,
15
- )
16
- from .constraints import ConstraintSet
17
- from .context import Symbol
18
- from .order_stack_bp import OrderPolicy, OrderStackModel
19
-
20
-
21
- EventT = TypeVar("EventT")
22
-
23
-
24
- @dataclass(frozen=True)
25
- class EventCodec(Generic[EventT]):
26
- """Encode project-specific events as hashable symbols.
27
-
28
- ``symbol_to_event`` is optional. It is only required when callers want to
29
- decode generated symbols back into event objects.
30
- """
31
-
32
- event_to_symbol: Callable[[EventT], Symbol]
33
- symbol_to_event: Mapping[Symbol, EventT] | Callable[[Symbol], EventT] | None = None
34
-
35
- @classmethod
36
- def identity(cls) -> "EventCodec[Symbol]":
37
- return cls(lambda event: event, lambda symbol: symbol)
38
-
39
- def encode_event(self, event: EventT) -> Symbol:
40
- return self.event_to_symbol(event)
41
-
42
- def encode_sequence(self, sequence: Sequence[EventT]) -> tuple[Symbol, ...]:
43
- return tuple(self.encode_event(event) for event in sequence)
44
-
45
- def encode_sequences(
46
- self,
47
- sequences: Iterable[Sequence[EventT]],
48
- ) -> tuple[tuple[Symbol, ...], ...]:
49
- return tuple(self.encode_sequence(sequence) for sequence in sequences)
50
-
51
- def decode_symbol(self, symbol: Symbol) -> EventT:
52
- decoder = self.symbol_to_event
53
- if decoder is None:
54
- raise ValueError("EventCodec has no symbol_to_event decoder")
55
- if isinstance(decoder, Mapping):
56
- return decoder[symbol]
57
- return decoder(symbol)
58
-
59
- def decode_sequence(self, sequence: Sequence[Symbol]) -> tuple[EventT, ...]:
60
- return tuple(self.decode_symbol(symbol) for symbol in sequence)
61
-
62
- def symbol_property(
63
- self,
64
- event_property: Callable[[EventT], object],
65
- ) -> Callable[[Symbol], object]:
66
- """Lift an event property function to generated symbols."""
67
-
68
- def property_from_symbol(symbol: Symbol) -> object:
69
- return event_property(self.decode_symbol(symbol))
70
-
71
- return property_from_symbol
72
-
73
- def with_decoder(
74
- self,
75
- symbol_to_event: Mapping[Symbol, EventT] | Callable[[Symbol], EventT],
76
- ) -> "EventCodec[EventT]":
77
- return EventCodec(self.event_to_symbol, symbol_to_event)
78
-
79
-
80
- @dataclass(frozen=True)
81
- class GeneratedEvents(Generic[EventT]):
82
- """Generated events with their encoded symbols and optional order trace."""
83
-
84
- events: tuple[EventT, ...]
85
- symbols: tuple[Symbol, ...]
86
- orders: tuple[int, ...] = ()
87
-
88
-
89
- @dataclass(frozen=True)
90
- class EventOrderStackBackend(Generic[EventT]):
91
- """Prepared constrained order-stack sampler for event objects."""
92
-
93
- backend: ConstrainedOrderStackBackend
94
- codec: EventCodec[EventT]
95
-
96
- @property
97
- def diagnostics(self) -> BackendDiagnostics:
98
- return self.backend.diagnostics
99
-
100
- def sample_symbols(self, *, rng: random.Random | int | None = None) -> tuple[Symbol, ...]:
101
- return self.backend.sample(rng=rng)
102
-
103
- def sample_events(self, *, rng: random.Random | int | None = None) -> tuple[EventT, ...]:
104
- return self.codec.decode_sequence(self.sample_symbols(rng=rng))
105
-
106
- def sample(self, *, rng: random.Random | int | None = None) -> tuple[EventT, ...]:
107
- return self.sample_events(rng=rng)
108
-
109
- def sample_events_with_orders(
110
- self,
111
- *,
112
- rng: random.Random | int | None = None,
113
- ) -> GeneratedEvents[EventT]:
114
- generated = self.backend.sample_with_orders(rng=rng)
115
- return GeneratedEvents(
116
- events=self.codec.decode_sequence(generated.sequence),
117
- symbols=generated.sequence,
118
- orders=generated.orders,
119
- )
120
-
121
- def sample_many_events(
122
- self,
123
- count: int,
124
- *,
125
- rng: random.Random | int | None = None,
126
- ) -> list[tuple[EventT, ...]]:
127
- return [
128
- self.codec.decode_sequence(sequence)
129
- for sequence in self.backend.sample_many(count, rng=rng)
130
- ]
131
-
132
- def sample_many_events_with_orders(
133
- self,
134
- count: int,
135
- *,
136
- rng: random.Random | int | None = None,
137
- ) -> list[GeneratedEvents[EventT]]:
138
- return [
139
- GeneratedEvents(
140
- events=self.codec.decode_sequence(generated.sequence),
141
- symbols=generated.sequence,
142
- orders=generated.orders,
143
- )
144
- for generated in self.backend.sample_many_with_orders(count, rng=rng)
145
- ]
146
-
147
-
148
- @dataclass(frozen=True)
149
- class EventOrderStackPlan(Generic[EventT]):
150
- """Prefix-independent event sampler plan with encode/decode glue."""
151
-
152
- plan: ConstrainedOrderStackPlan
153
- codec: EventCodec[EventT]
154
-
155
- @property
156
- def length(self) -> int:
157
- return self.plan.length
158
-
159
- def for_prefix(self, prefix: Sequence[EventT]) -> EventOrderStackBackend[EventT]:
160
- backend = self.plan.for_prefix(self.codec.encode_sequence(prefix))
161
- return EventOrderStackBackend(backend=backend, codec=self.codec)
162
-
163
- def sample_events(
164
- self,
165
- *,
166
- prefix: Sequence[EventT],
167
- rng: random.Random | int | None = None,
168
- ) -> tuple[EventT, ...]:
169
- return self.for_prefix(prefix).sample_events(rng=rng)
170
-
171
- def sample_events_with_orders(
172
- self,
173
- *,
174
- prefix: Sequence[EventT],
175
- rng: random.Random | int | None = None,
176
- ) -> GeneratedEvents[EventT]:
177
- return self.for_prefix(prefix).sample_events_with_orders(rng=rng)
178
-
179
-
180
- def prepare_constrained_order_stack_from_events(
181
- sequences: Iterable[Sequence[EventT]],
182
- constraints: ConstraintSet | None = None,
183
- *,
184
- codec: EventCodec[EventT],
185
- max_order: int,
186
- length: int,
187
- prefix: Sequence[EventT],
188
- policy: OrderPolicy | None = None,
189
- start_event: EventT | None = None,
190
- end_event: EventT | None = None,
191
- alphabet: Iterable[Symbol] | None = None,
192
- prefer_dense_forbidden: bool = True,
193
- minimize_source_graphs: bool = False,
194
- ) -> EventOrderStackBackend[EventT]:
195
- """Build and prepare a constrained order-stack backend from event sequences."""
196
-
197
- plan = prepare_constrained_order_stack_plan_from_events(
198
- sequences,
199
- constraints,
200
- codec=codec,
201
- max_order=max_order,
202
- length=length,
203
- policy=policy,
204
- start_event=start_event,
205
- end_event=end_event,
206
- alphabet=alphabet,
207
- prefer_dense_forbidden=prefer_dense_forbidden,
208
- minimize_source_graphs=minimize_source_graphs,
209
- )
210
- return plan.for_prefix(prefix)
211
-
212
-
213
- def prepare_constrained_order_stack_plan_from_events(
214
- sequences: Iterable[Sequence[EventT]],
215
- constraints: ConstraintSet | None = None,
216
- *,
217
- codec: EventCodec[EventT],
218
- max_order: int,
219
- length: int,
220
- policy: OrderPolicy | None = None,
221
- start_event: EventT | None = None,
222
- end_event: EventT | None = None,
223
- alphabet: Iterable[Symbol] | None = None,
224
- prefer_dense_forbidden: bool = True,
225
- minimize_source_graphs: bool = False,
226
- ) -> EventOrderStackPlan[EventT]:
227
- """Build an order-stack model from events and prepare a prefixless plan."""
228
-
229
- encoded_sequences = codec.encode_sequences(sequences)
230
- model = OrderStackModel.from_sequences(
231
- encoded_sequences,
232
- max_order=max_order,
233
- start_symbol=None if start_event is None else codec.encode_event(start_event),
234
- end_symbol=None if end_event is None else codec.encode_event(end_event),
235
- )
236
- plan = prepare_constrained_order_stack_plan(
237
- model,
238
- constraints,
239
- length=length,
240
- policy=policy,
241
- alphabet=alphabet,
242
- prefer_dense_forbidden=prefer_dense_forbidden,
243
- minimize_source_graphs=minimize_source_graphs,
244
- )
245
- return EventOrderStackPlan(plan=plan, codec=codec)
246
-
247
-
248
- def infer_symbol_to_event(
249
- sequences: Iterable[Sequence[EventT]],
250
- event_to_symbol: Callable[[EventT], Symbol],
251
- *,
252
- strict: bool = True,
253
- ) -> dict[Symbol, EventT]:
254
- """Infer a decoder lookup from training events.
255
-
256
- In strict mode, two unequal events mapping to the same symbol raise because
257
- decoding would be ambiguous. With ``strict=False``, the first event seen for
258
- each symbol is kept.
259
- """
260
-
261
- lookup: dict[Symbol, EventT] = {}
262
- for sequence in sequences:
263
- for event in sequence:
264
- symbol = event_to_symbol(event)
265
- if symbol in lookup:
266
- if strict and lookup[symbol] != event:
267
- raise ValueError(f"symbol {symbol!r} maps to multiple events")
268
- continue
269
- lookup[symbol] = event
270
- return lookup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/augmentation.py DELETED
@@ -1,505 +0,0 @@
1
- """Virtual data augmentation for order-stack models.
2
-
3
- The classes here keep the generated symbol space absolute. Transformations are
4
- used only to evaluate continuation counts as if transformed copies of the
5
- training sequences had been materialized.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from collections import Counter
11
- from collections.abc import Callable, Iterable, Iterator, Sequence
12
- from dataclasses import dataclass, field
13
- import time
14
-
15
- from .context import Context, Symbol, _as_context
16
- from .order_stack_bp import FixedOrderContextGraph, OrderStackModel
17
- from .order_stack_bp import StackEdge
18
-
19
-
20
- @dataclass(frozen=True)
21
- class SymbolTransform:
22
- """A deterministic finite augmentation transform over symbols."""
23
-
24
- name: str
25
- apply_symbol: Callable[[Symbol], Symbol]
26
- inverse_symbol: Callable[[Symbol], Symbol]
27
- weight: float = 1.0
28
-
29
- def apply_context(self, context: Sequence[Symbol]) -> Context:
30
- return tuple(self.apply_symbol(symbol) for symbol in context)
31
-
32
- def inverse_context(self, context: Sequence[Symbol]) -> Context:
33
- return tuple(self.inverse_symbol(symbol) for symbol in context)
34
-
35
-
36
- def integer_shift_transform(
37
- offset: int,
38
- *,
39
- fixed_symbols: Iterable[Symbol] = (),
40
- name: str | None = None,
41
- ) -> SymbolTransform:
42
- """Return a symbol transform that adds an integer offset.
43
-
44
- ``fixed_symbols`` are left unchanged. This is useful for start/end sentinel
45
- symbols in pitch models.
46
- """
47
-
48
- fixed = frozenset(fixed_symbols)
49
- shift = int(offset)
50
-
51
- def apply_symbol(symbol: Symbol) -> Symbol:
52
- if symbol in fixed:
53
- return symbol
54
- return int(symbol) + shift
55
-
56
- def inverse_symbol(symbol: Symbol) -> Symbol:
57
- if symbol in fixed:
58
- return symbol
59
- return int(symbol) - shift
60
-
61
- return SymbolTransform(
62
- name=name or f"shift_{shift:+d}",
63
- apply_symbol=apply_symbol,
64
- inverse_symbol=inverse_symbol,
65
- )
66
-
67
-
68
- def integer_shift_transforms(
69
- offsets: Iterable[int],
70
- *,
71
- fixed_symbols: Iterable[Symbol] = (),
72
- ) -> tuple[SymbolTransform, ...]:
73
- """Return integer-shift transforms for all requested offsets."""
74
-
75
- return tuple(
76
- integer_shift_transform(offset, fixed_symbols=fixed_symbols)
77
- for offset in offsets
78
- )
79
-
80
-
81
- def materialize_transformed_sequences(
82
- sequences: Iterable[Sequence[Symbol]],
83
- transforms: Iterable[SymbolTransform],
84
- ) -> tuple[tuple[Symbol, ...], ...]:
85
- """Materialize transformed copies for validation and comparison."""
86
-
87
- material = tuple(tuple(sequence) for sequence in sequences)
88
- return tuple(
89
- transform.apply_context(sequence)
90
- for transform in transforms
91
- for sequence in material
92
- )
93
-
94
-
95
- @dataclass
96
- class VirtualAugmentedOrderStackModel:
97
- """Order-stack model with exact virtual transformed-corpus counts.
98
-
99
- The model answers continuation queries with the same counts as an explicit
100
- augmented corpus:
101
-
102
- ``C_aug(c -> y) = sum_g C_base(g^-1(c) -> g^-1(y))``.
103
- """
104
-
105
- base_model: OrderStackModel
106
- transforms: tuple[SymbolTransform, ...]
107
- _counts_cache: dict[Context, Counter[Symbol]] = field(default_factory=dict, init=False)
108
- _distribution_cache: dict[tuple[Context, int | None], tuple[tuple[tuple[Symbol, float], ...], int | None]] = field(
109
- default_factory=dict,
110
- init=False,
111
- repr=False,
112
- )
113
- _graph_cache: dict[int, FixedOrderContextGraph] = field(default_factory=dict, init=False)
114
- _contexts_cache: dict[int, tuple[Context, ...]] = field(default_factory=dict, init=False)
115
- _lazy_graph_cache: dict[tuple[Context, int], dict[int, "LazyVirtualFixedOrderContextGraph"]] = field(
116
- default_factory=dict,
117
- init=False,
118
- repr=False,
119
- )
120
- _lazy_plan_graph_cache: dict[int, dict[int, "LazyVirtualFixedOrderContextGraph"]] = field(
121
- default_factory=dict,
122
- init=False,
123
- repr=False,
124
- )
125
- _apply_symbol_cache: dict[tuple[int, Symbol], Symbol] = field(
126
- default_factory=dict,
127
- init=False,
128
- repr=False,
129
- )
130
- _inverse_symbol_cache: dict[tuple[int, Symbol], Symbol] = field(
131
- default_factory=dict,
132
- init=False,
133
- repr=False,
134
- )
135
- _inverse_context_cache: dict[tuple[int, Context], Context] = field(
136
- default_factory=dict,
137
- init=False,
138
- repr=False,
139
- )
140
- _context_materialization_seconds: float = field(default=0.0, init=False, repr=False)
141
- _context_materialization_calls: int = field(default=0, init=False, repr=False)
142
- _context_materialization_cache_hits: int = field(default=0, init=False, repr=False)
143
- _context_materialization_cache_misses: int = field(default=0, init=False, repr=False)
144
- _augmented_count_calls: int = field(default=0, init=False, repr=False)
145
- _augmented_count_cache_hits: int = field(default=0, init=False, repr=False)
146
- _augmented_count_cache_misses: int = field(default=0, init=False, repr=False)
147
-
148
- def __post_init__(self) -> None:
149
- if not self.transforms:
150
- raise ValueError("at least one transform is required")
151
- if any(transform.weight <= 0.0 for transform in self.transforms):
152
- raise ValueError("transform weights must be positive")
153
- self.max_order = self.base_model.max_order
154
- self.forbidden_symbols = frozenset(
155
- self._apply_symbol(transform_index, symbol)
156
- for transform_index, _transform in enumerate(self.transforms)
157
- for symbol in self.base_model.forbidden_symbols
158
- )
159
- self.alphabet = frozenset(
160
- self._apply_symbol(transform_index, symbol)
161
- for transform_index, _transform in enumerate(self.transforms)
162
- for symbol in self.base_model.alphabet
163
- )
164
-
165
- @classmethod
166
- def from_sequences(
167
- cls,
168
- sequences: Iterable[Sequence[Symbol]],
169
- *,
170
- max_order: int,
171
- transforms: Iterable[SymbolTransform],
172
- start_symbol: Symbol | None = None,
173
- end_symbol: Symbol | None = None,
174
- ) -> "VirtualAugmentedOrderStackModel":
175
- base_model = OrderStackModel.from_sequences(
176
- sequences,
177
- max_order=max_order,
178
- start_symbol=start_symbol,
179
- end_symbol=end_symbol,
180
- )
181
- return cls(base_model=base_model, transforms=tuple(transforms))
182
-
183
- @property
184
- def base_context_count(self) -> int:
185
- return len(self.base_model.counts)
186
-
187
- @property
188
- def virtual_event_multiplier(self) -> int:
189
- return len(self.transforms)
190
-
191
- def virtual_context_count(self, order: int | None = None) -> int:
192
- if order is None:
193
- order = self.max_order
194
- return sum(1 for _context in self.iter_contexts(order))
195
-
196
- def iter_contexts(self, order: int) -> Iterable[Context]:
197
- self._context_materialization_calls += 1
198
- cached = self._contexts_cache.get(order)
199
- if cached is not None:
200
- self._context_materialization_cache_hits += 1
201
- return cached
202
- self._context_materialization_cache_misses += 1
203
- started = time.perf_counter()
204
- contexts: set[Context] = set()
205
- for context in self.base_model.counts:
206
- if len(context) > order:
207
- continue
208
- for transform_index, _transform in enumerate(self.transforms):
209
- contexts.add(
210
- tuple(
211
- self._apply_symbol(transform_index, symbol)
212
- for symbol in context
213
- )
214
- )
215
- result = tuple(contexts)
216
- self._contexts_cache[order] = result
217
- self._context_materialization_seconds += time.perf_counter() - started
218
- return result
219
-
220
- def augmented_counts(self, context: Iterable[Symbol] | Context) -> Counter[Symbol]:
221
- self._augmented_count_calls += 1
222
- state = _as_context(context)
223
- cached = self._counts_cache.get(state)
224
- if cached is not None:
225
- self._augmented_count_cache_hits += 1
226
- return cached
227
-
228
- self._augmented_count_cache_misses += 1
229
- counts: Counter[Symbol] = Counter()
230
- for transform_index, _transform in enumerate(self.transforms):
231
- inverse_context = self._inverse_context(transform_index, state)
232
- base_counts = self.base_model.counts.get(inverse_context)
233
- if not base_counts:
234
- continue
235
- for base_symbol, count in base_counts.items():
236
- counts[self._apply_symbol(transform_index, base_symbol)] += (
237
- count * self.transforms[transform_index].weight
238
- )
239
- self._counts_cache[state] = counts
240
- return counts
241
-
242
- def virtual_diagnostics(self) -> dict[str, int | float]:
243
- return {
244
- "virtual_context_materialization_seconds": self._context_materialization_seconds,
245
- "virtual_context_materialization_calls": self._context_materialization_calls,
246
- "virtual_context_materialization_cache_hits": (
247
- self._context_materialization_cache_hits
248
- ),
249
- "virtual_context_materialization_cache_misses": (
250
- self._context_materialization_cache_misses
251
- ),
252
- "augmented_count_calls": self._augmented_count_calls,
253
- "augmented_count_cache_hits": self._augmented_count_cache_hits,
254
- "augmented_count_cache_misses": self._augmented_count_cache_misses,
255
- }
256
-
257
- def _apply_symbol(self, transform_index: int, symbol: Symbol) -> Symbol:
258
- key = (transform_index, symbol)
259
- cached = self._apply_symbol_cache.get(key)
260
- if cached is not None:
261
- return cached
262
- transformed = self.transforms[transform_index].apply_symbol(symbol)
263
- self._apply_symbol_cache[key] = transformed
264
- return transformed
265
-
266
- def _inverse_symbol(self, transform_index: int, symbol: Symbol) -> Symbol:
267
- key = (transform_index, symbol)
268
- cached = self._inverse_symbol_cache.get(key)
269
- if cached is not None:
270
- return cached
271
- transformed = self.transforms[transform_index].inverse_symbol(symbol)
272
- self._inverse_symbol_cache[key] = transformed
273
- return transformed
274
-
275
- def _inverse_context(self, transform_index: int, context: Sequence[Symbol]) -> Context:
276
- state = _as_context(context)
277
- key = (transform_index, state)
278
- cached = self._inverse_context_cache.get(key)
279
- if cached is not None:
280
- return cached
281
- transformed = tuple(self._inverse_symbol(transform_index, symbol) for symbol in state)
282
- self._inverse_context_cache[key] = transformed
283
- return transformed
284
-
285
- def longest_available_suffix(
286
- self,
287
- context: Iterable[Symbol] | Context,
288
- *,
289
- max_order: int | None = None,
290
- ) -> Context | None:
291
- state = _as_context(context)
292
- order_limit = min(self.max_order if max_order is None else max_order, len(state))
293
- for order in range(order_limit, 0, -1):
294
- suffix = state[-order:]
295
- if self.augmented_counts(suffix):
296
- return suffix
297
- return None
298
-
299
- def continuation_distribution_with_order(
300
- self,
301
- context: Iterable[Symbol] | Context,
302
- *,
303
- max_order: int | None = None,
304
- ) -> tuple[tuple[tuple[Symbol, float], ...], int | None]:
305
- state = _as_context(context)
306
- cache_key = (state, max_order)
307
- cached = self._distribution_cache.get(cache_key)
308
- if cached is not None:
309
- return cached
310
-
311
- suffix = self.longest_available_suffix(state, max_order=max_order)
312
- if suffix is None:
313
- result = ((), None)
314
- self._distribution_cache[cache_key] = result
315
- return result
316
-
317
- counts = self.augmented_counts(suffix)
318
- total = float(sum(counts.values()))
319
- if total <= 0.0:
320
- result = ((), None)
321
- else:
322
- result = (
323
- tuple((symbol, float(count) / total) for symbol, count in counts.items()),
324
- len(suffix),
325
- )
326
- self._distribution_cache[cache_key] = result
327
- return result
328
-
329
- def compile_graph(self, order: int) -> FixedOrderContextGraph:
330
- cached = self._graph_cache.get(order)
331
- if cached is not None:
332
- return cached
333
- graph = FixedOrderContextGraph.from_model(self, order=order)
334
- self._graph_cache[order] = graph
335
- return graph
336
-
337
- def compile_graphs_for_plan(
338
- self,
339
- *,
340
- length: int,
341
- ) -> dict[int, "LazyVirtualFixedOrderContextGraph"]:
342
- if length < 0:
343
- raise ValueError("length must be non-negative")
344
- cache_key = int(length)
345
- cached = self._lazy_plan_graph_cache.get(cache_key)
346
- if cached is not None:
347
- return cached
348
-
349
- graphs = self._new_lazy_graphs()
350
- self._lazy_plan_graph_cache[cache_key] = graphs
351
- return graphs
352
-
353
- def compile_graphs_for_prefix(
354
- self,
355
- *,
356
- prefix: Sequence[Symbol],
357
- length: int,
358
- ) -> dict[int, "LazyVirtualFixedOrderContextGraph"]:
359
- if length < 0:
360
- raise ValueError("length must be non-negative")
361
- prefix_context = tuple(prefix)
362
- cache_key = (prefix_context, int(length))
363
- cached = self._lazy_graph_cache.get(cache_key)
364
- if cached is not None:
365
- return cached
366
-
367
- graphs = self._new_lazy_graphs()
368
- self._lazy_graph_cache[cache_key] = graphs
369
- return graphs
370
-
371
- def _new_lazy_graphs(self) -> dict[int, "LazyVirtualFixedOrderContextGraph"]:
372
- return {
373
- order: LazyVirtualFixedOrderContextGraph(
374
- self,
375
- order=order,
376
- )
377
- for order in range(1, self.max_order + 1)
378
- }
379
-
380
-
381
- class _LazyOutgoing:
382
- def __init__(self, graph: "LazyVirtualFixedOrderContextGraph") -> None:
383
- self.graph = graph
384
-
385
- def __getitem__(self, state: int) -> list[StackEdge]:
386
- return self.graph.outgoing_for_state(state)
387
-
388
- def __iter__(self) -> Iterator[list[StackEdge]]:
389
- for state in range(len(self.graph.contexts)):
390
- yield self.graph.outgoing_for_state(state)
391
-
392
- def __len__(self) -> int:
393
- return len(self.graph.contexts)
394
-
395
-
396
- class LazyVirtualFixedOrderContextGraph:
397
- """Lazily materialized fixed-order graph for virtual augmentation."""
398
-
399
- def __init__(
400
- self,
401
- model: VirtualAugmentedOrderStackModel,
402
- *,
403
- order: int,
404
- allowed_contexts: Iterable[Context] | None = None,
405
- ) -> None:
406
- if order < 1 or order > model.max_order:
407
- raise ValueError(f"order must be between 1 and {model.max_order}")
408
- self.model = model
409
- self.order = int(order)
410
- self._allowed_contexts_source = (
411
- None if allowed_contexts is None else tuple(allowed_contexts)
412
- )
413
- self._allowed_contexts: set[Context] | None = None
414
- self.contexts: list[Context] = []
415
- self.context_to_id: dict[Context, int] = {}
416
- self._outgoing_cache: dict[int, list[StackEdge]] = {}
417
- self.outgoing = _LazyOutgoing(self)
418
- self.outgoing_row_calls = 0
419
- self.outgoing_row_cache_hits = 0
420
- self.outgoing_row_cache_misses = 0
421
- self.outgoing_row_materialization_seconds = 0.0
422
-
423
- @property
424
- def allowed_contexts(self) -> set[Context]:
425
- return self._ensure_allowed_contexts()
426
-
427
- def truncate_context(self, context: Iterable[Symbol] | Context) -> Context:
428
- state = _as_context(context)
429
- if len(state) <= self.order:
430
- return state
431
- return state[-self.order:]
432
-
433
- def next_context(self, context: Iterable[Symbol] | Context, symbol: Symbol) -> Context:
434
- return self.truncate_context(_as_context(context) + (symbol,))
435
-
436
- def state_id(self, context: Iterable[Symbol] | Context) -> int | None:
437
- state = self.truncate_context(context)
438
- if state not in self._ensure_allowed_contexts():
439
- return None
440
- return self._add_context(state)
441
-
442
- @property
443
- def edge_count(self) -> int:
444
- return sum(len(edges) for edges in self.outgoing)
445
-
446
- def outgoing_for_state(self, state: int) -> list[StackEdge]:
447
- self.outgoing_row_calls += 1
448
- cached = self._outgoing_cache.get(state)
449
- if cached is not None:
450
- self.outgoing_row_cache_hits += 1
451
- return cached
452
- self.outgoing_row_cache_misses += 1
453
- started = time.perf_counter()
454
- context = self.contexts[state]
455
- distribution, effective_order = self.model.continuation_distribution_with_order(
456
- context,
457
- max_order=self.order,
458
- )
459
- edges: list[StackEdge] = []
460
- order = self.order
461
- allowed_contexts = self._ensure_allowed_contexts()
462
- for symbol, probability in distribution:
463
- candidate_context = context + (symbol,)
464
- dst_context = (
465
- candidate_context
466
- if len(candidate_context) <= order
467
- else candidate_context[-order:]
468
- )
469
- if dst_context not in allowed_contexts:
470
- continue
471
- dst = self._add_context(dst_context)
472
- edges.append(
473
- StackEdge(
474
- src=state,
475
- dst=dst,
476
- symbol=symbol,
477
- probability=float(probability),
478
- order=effective_order or 0,
479
- )
480
- )
481
- self._outgoing_cache[state] = edges
482
- self.outgoing_row_materialization_seconds += time.perf_counter() - started
483
- return edges
484
-
485
- def _ensure_allowed_contexts(self) -> set[Context]:
486
- allowed_contexts = self._allowed_contexts
487
- if allowed_contexts is not None:
488
- return allowed_contexts
489
- source = (
490
- self.model.iter_contexts(self.order)
491
- if self._allowed_contexts_source is None
492
- else self._allowed_contexts_source
493
- )
494
- allowed_contexts = {self.truncate_context(context) for context in source}
495
- self._allowed_contexts = allowed_contexts
496
- return allowed_contexts
497
-
498
- def _add_context(self, context: Context) -> int:
499
- found = self.context_to_id.get(context)
500
- if found is not None:
501
- return found
502
- context_id = len(self.contexts)
503
- self.context_to_id[context] = context_id
504
- self.contexts.append(context)
505
- return context_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/backend.py DELETED
@@ -1,1066 +0,0 @@
1
- """Stable public backend entry points.
2
-
3
- This module is the library-facing layer. It compiles generic constraint
4
- specifications and delegates to the current BP engines. Future optimized
5
- engines should be wired behind these functions without changing the public
6
- surface.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import bisect
12
- from collections.abc import Callable, Hashable, Iterable, Sequence
13
- from dataclasses import dataclass
14
- import random
15
-
16
- from .acceptors import DFA
17
- from .constraint_builders import combine_constraints
18
- from .constraints import ConstraintSet, compile_constraints
19
- from .context import Symbol
20
- from .order_stack_bp import (
21
- OrderPolicy,
22
- OrderSampleStep,
23
- OrderStackBPPlan,
24
- OrderStackModel,
25
- RegularOrderStackBPResult,
26
- RegularOrderStackBPPlan,
27
- OrderStackBPResult,
28
- prepare_order_stack_bp,
29
- prepare_order_stack_masked_dfa_bp,
30
- run_order_stack_bp,
31
- run_order_stack_masked_dfa_bp,
32
- )
33
- from .positional_bp import AllowedForbiddenSymbols
34
-
35
-
36
- @dataclass(frozen=True)
37
- class GeneratedSequence:
38
- """A generated sequence plus optional order diagnostics."""
39
-
40
- sequence: tuple[Symbol, ...]
41
- orders: tuple[int, ...] = ()
42
-
43
-
44
- @dataclass(frozen=True)
45
- class BackendDiagnostics:
46
- """Runtime-independent diagnostics exposed by prepared backends."""
47
-
48
- backend: str
49
- length: int
50
- max_order: int
51
- context_states: int
52
- context_edges: int
53
- regular_product_states: int | None = None
54
- regular_product_states_time_indexed: int | None = None
55
- regular_product_edges: int | None = None
56
- regular_transition_rows: int | None = None
57
- regular_transition_row_cache_hits: int | None = None
58
- regular_transition_row_cache_misses: int | None = None
59
- regular_accepted_transitions: int | None = None
60
- regular_beta_state_expansions: int | None = None
61
- regular_beta_cache_hits: int | None = None
62
- regular_beta_cache_misses: int | None = None
63
- regular_acceptor_symbol_transition_cache_hits: int | None = None
64
- regular_acceptor_symbol_transition_cache_misses: int | None = None
65
- duration_view_quotient_states: int | None = None
66
- duration_view_quotient_classes: int | None = None
67
- duration_view_quotient_state_reduction: float | None = None
68
- duration_view_quotient_edges: int | None = None
69
- duration_view_quotient_projected_edges: int | None = None
70
- duration_view_quotient_ignored_edges: int | None = None
71
- duration_view_quotient_quotient_edges: int | None = None
72
- duration_view_quotient_projected_edge_reduction: float | None = None
73
- duration_view_quotient_max_class_size: int | None = None
74
- duration_view_quotient_refinement_rounds: int | None = None
75
- duration_view_quotient_seconds: float | None = None
76
- duration_view_quotient_order_stats: tuple[dict[str, object], ...] = ()
77
- virtual_context_materialization_seconds: float | None = None
78
- virtual_context_materialization_calls: int | None = None
79
- virtual_context_materialization_cache_hits: int | None = None
80
- virtual_context_materialization_cache_misses: int | None = None
81
- augmented_count_calls: int | None = None
82
- augmented_count_cache_hits: int | None = None
83
- augmented_count_cache_misses: int | None = None
84
- virtual_outgoing_row_calls: int | None = None
85
- virtual_outgoing_row_cache_hits: int | None = None
86
- virtual_outgoing_row_cache_misses: int | None = None
87
- success_mass: float | None = None
88
- start_order_masses: tuple[tuple[int, float], ...] = ()
89
-
90
- def as_dict(self) -> dict[str, object]:
91
- return {
92
- "backend": self.backend,
93
- "length": self.length,
94
- "max_order": self.max_order,
95
- "context_states": self.context_states,
96
- "context_edges": self.context_edges,
97
- "regular_product_states": self.regular_product_states,
98
- "regular_product_states_time_indexed": self.regular_product_states_time_indexed,
99
- "regular_product_edges": self.regular_product_edges,
100
- "regular_transition_rows": self.regular_transition_rows,
101
- "regular_transition_row_cache_hits": self.regular_transition_row_cache_hits,
102
- "regular_transition_row_cache_misses": self.regular_transition_row_cache_misses,
103
- "regular_accepted_transitions": self.regular_accepted_transitions,
104
- "regular_beta_state_expansions": self.regular_beta_state_expansions,
105
- "regular_beta_cache_hits": self.regular_beta_cache_hits,
106
- "regular_beta_cache_misses": self.regular_beta_cache_misses,
107
- "regular_acceptor_symbol_transition_cache_hits": (
108
- self.regular_acceptor_symbol_transition_cache_hits
109
- ),
110
- "regular_acceptor_symbol_transition_cache_misses": (
111
- self.regular_acceptor_symbol_transition_cache_misses
112
- ),
113
- "duration_view_quotient_states": self.duration_view_quotient_states,
114
- "duration_view_quotient_classes": self.duration_view_quotient_classes,
115
- "duration_view_quotient_state_reduction": (
116
- self.duration_view_quotient_state_reduction
117
- ),
118
- "duration_view_quotient_edges": self.duration_view_quotient_edges,
119
- "duration_view_quotient_projected_edges": (
120
- self.duration_view_quotient_projected_edges
121
- ),
122
- "duration_view_quotient_ignored_edges": (
123
- self.duration_view_quotient_ignored_edges
124
- ),
125
- "duration_view_quotient_quotient_edges": (
126
- self.duration_view_quotient_quotient_edges
127
- ),
128
- "duration_view_quotient_projected_edge_reduction": (
129
- self.duration_view_quotient_projected_edge_reduction
130
- ),
131
- "duration_view_quotient_max_class_size": (
132
- self.duration_view_quotient_max_class_size
133
- ),
134
- "duration_view_quotient_refinement_rounds": (
135
- self.duration_view_quotient_refinement_rounds
136
- ),
137
- "duration_view_quotient_seconds": self.duration_view_quotient_seconds,
138
- "duration_view_quotient_order_stats": (
139
- self.duration_view_quotient_order_stats
140
- ),
141
- "virtual_context_materialization_seconds": (
142
- self.virtual_context_materialization_seconds
143
- ),
144
- "virtual_context_materialization_calls": self.virtual_context_materialization_calls,
145
- "virtual_context_materialization_cache_hits": (
146
- self.virtual_context_materialization_cache_hits
147
- ),
148
- "virtual_context_materialization_cache_misses": (
149
- self.virtual_context_materialization_cache_misses
150
- ),
151
- "augmented_count_calls": self.augmented_count_calls,
152
- "augmented_count_cache_hits": self.augmented_count_cache_hits,
153
- "augmented_count_cache_misses": self.augmented_count_cache_misses,
154
- "virtual_outgoing_row_calls": self.virtual_outgoing_row_calls,
155
- "virtual_outgoing_row_cache_hits": self.virtual_outgoing_row_cache_hits,
156
- "virtual_outgoing_row_cache_misses": self.virtual_outgoing_row_cache_misses,
157
- "success_mass": self.success_mass,
158
- "start_order_masses": self.start_order_masses,
159
- }
160
-
161
-
162
- @dataclass(frozen=True)
163
- class UntilLengthDiagnostics:
164
- """Diagnostics for one feasible first-hit continuation length."""
165
-
166
- length: int
167
- weight: float
168
- backend: BackendDiagnostics
169
-
170
- def as_dict(self) -> dict[str, object]:
171
- return {
172
- "length": self.length,
173
- "weight": self.weight,
174
- "backend": self.backend.as_dict(),
175
- }
176
-
177
-
178
- @dataclass(frozen=True)
179
- class UntilOrderStackDiagnostics:
180
- """Diagnostics exposed by variable-length first-hit backends."""
181
-
182
- backend: str
183
- min_length: int
184
- max_length: int
185
- feasible_lengths: tuple[int, ...]
186
- length_weights: tuple[tuple[int, float], ...]
187
- length_diagnostics: tuple[UntilLengthDiagnostics, ...]
188
-
189
- def as_dict(self) -> dict[str, object]:
190
- return {
191
- "backend": self.backend,
192
- "min_length": self.min_length,
193
- "max_length": self.max_length,
194
- "feasible_lengths": self.feasible_lengths,
195
- "length_weights": self.length_weights,
196
- "length_diagnostics": tuple(
197
- diagnostic.as_dict() for diagnostic in self.length_diagnostics
198
- ),
199
- }
200
-
201
-
202
- def _virtual_model_diagnostics(model: object) -> dict[str, object]:
203
- diagnostics = getattr(model, "virtual_diagnostics", None)
204
- if callable(diagnostics):
205
- return dict(diagnostics())
206
- return {}
207
-
208
-
209
- def _virtual_graph_outgoing_diagnostics(
210
- graphs: object,
211
- ) -> dict[str, int]:
212
- values = tuple(graphs.values()) if isinstance(graphs, dict) else ()
213
- return {
214
- "virtual_outgoing_row_calls": sum(
215
- int(getattr(graph, "outgoing_row_calls", 0)) for graph in values
216
- ),
217
- "virtual_outgoing_row_cache_hits": sum(
218
- int(getattr(graph, "outgoing_row_cache_hits", 0))
219
- for graph in values
220
- ),
221
- "virtual_outgoing_row_cache_misses": sum(
222
- int(getattr(graph, "outgoing_row_cache_misses", 0))
223
- for graph in values
224
- ),
225
- }
226
-
227
-
228
- @dataclass(frozen=True)
229
- class ConstrainedOrderStackBackend:
230
- """Prepared reusable constrained order-stack sampler.
231
-
232
- External projects can keep this object around after compilation/BP and call
233
- the sampling methods repeatedly. The concrete result remains available for
234
- advanced inspection, but regular users should prefer this stable wrapper.
235
- """
236
-
237
- result: OrderStackBPResult | RegularOrderStackBPResult
238
-
239
- @property
240
- def diagnostics(self) -> BackendDiagnostics:
241
- is_regular = isinstance(self.result, RegularOrderStackBPResult)
242
- virtual_diagnostics = _virtual_model_diagnostics(self.result.model)
243
- virtual_outgoing_diagnostics = _virtual_graph_outgoing_diagnostics(
244
- self.result.graphs,
245
- )
246
- duration_view = (
247
- self.result.duration_view_quotient_diagnostics
248
- if is_regular
249
- else None
250
- )
251
- return BackendDiagnostics(
252
- backend="order_stack_regular" if is_regular else "order_stack_positional",
253
- length=self.result.length,
254
- max_order=self.result.model.max_order,
255
- context_states=self.result.context_state_count,
256
- context_edges=self.result.context_edge_count,
257
- regular_product_states=self.result.product_state_count if is_regular else None,
258
- regular_product_states_time_indexed=(
259
- self.result.time_indexed_product_state_count if is_regular else None
260
- ),
261
- regular_product_edges=self.result.product_edge_count if is_regular else None,
262
- regular_transition_rows=(
263
- self.result.regular_transition_row_count if is_regular else None
264
- ),
265
- regular_transition_row_cache_hits=(
266
- self.result.regular_transition_row_cache_hits if is_regular else None
267
- ),
268
- regular_transition_row_cache_misses=(
269
- self.result.regular_transition_row_cache_misses if is_regular else None
270
- ),
271
- regular_accepted_transitions=(
272
- self.result.regular_accepted_transition_count if is_regular else None
273
- ),
274
- regular_beta_state_expansions=(
275
- self.result.regular_beta_state_expansions if is_regular else None
276
- ),
277
- regular_beta_cache_hits=(
278
- self.result.regular_beta_cache_hits if is_regular else None
279
- ),
280
- regular_beta_cache_misses=(
281
- self.result.regular_beta_cache_misses if is_regular else None
282
- ),
283
- regular_acceptor_symbol_transition_cache_hits=(
284
- self.result.regular_acceptor_symbol_transition_cache_hits
285
- if is_regular
286
- else None
287
- ),
288
- regular_acceptor_symbol_transition_cache_misses=(
289
- self.result.regular_acceptor_symbol_transition_cache_misses
290
- if is_regular
291
- else None
292
- ),
293
- duration_view_quotient_states=(
294
- duration_view.states if duration_view is not None else None
295
- ),
296
- duration_view_quotient_classes=(
297
- duration_view.classes if duration_view is not None else None
298
- ),
299
- duration_view_quotient_state_reduction=(
300
- duration_view.state_reduction if duration_view is not None else None
301
- ),
302
- duration_view_quotient_edges=(
303
- duration_view.edges if duration_view is not None else None
304
- ),
305
- duration_view_quotient_projected_edges=(
306
- duration_view.projected_edges if duration_view is not None else None
307
- ),
308
- duration_view_quotient_ignored_edges=(
309
- duration_view.ignored_edges if duration_view is not None else None
310
- ),
311
- duration_view_quotient_quotient_edges=(
312
- duration_view.quotient_edges if duration_view is not None else None
313
- ),
314
- duration_view_quotient_projected_edge_reduction=(
315
- duration_view.projected_edge_reduction
316
- if duration_view is not None
317
- else None
318
- ),
319
- duration_view_quotient_max_class_size=(
320
- duration_view.max_class_size if duration_view is not None else None
321
- ),
322
- duration_view_quotient_refinement_rounds=(
323
- duration_view.refinement_rounds if duration_view is not None else None
324
- ),
325
- duration_view_quotient_seconds=(
326
- duration_view.seconds if duration_view is not None else None
327
- ),
328
- duration_view_quotient_order_stats=(
329
- tuple(
330
- stats.as_dict()
331
- for stats in duration_view.orders
332
- )
333
- if duration_view is not None
334
- else ()
335
- ),
336
- virtual_context_materialization_seconds=virtual_diagnostics.get(
337
- "virtual_context_materialization_seconds",
338
- ),
339
- virtual_context_materialization_calls=virtual_diagnostics.get(
340
- "virtual_context_materialization_calls",
341
- ),
342
- virtual_context_materialization_cache_hits=virtual_diagnostics.get(
343
- "virtual_context_materialization_cache_hits",
344
- ),
345
- virtual_context_materialization_cache_misses=virtual_diagnostics.get(
346
- "virtual_context_materialization_cache_misses",
347
- ),
348
- augmented_count_calls=virtual_diagnostics.get("augmented_count_calls"),
349
- augmented_count_cache_hits=virtual_diagnostics.get("augmented_count_cache_hits"),
350
- augmented_count_cache_misses=virtual_diagnostics.get(
351
- "augmented_count_cache_misses",
352
- ),
353
- virtual_outgoing_row_calls=virtual_outgoing_diagnostics[
354
- "virtual_outgoing_row_calls"
355
- ],
356
- virtual_outgoing_row_cache_hits=virtual_outgoing_diagnostics[
357
- "virtual_outgoing_row_cache_hits"
358
- ],
359
- virtual_outgoing_row_cache_misses=virtual_outgoing_diagnostics[
360
- "virtual_outgoing_row_cache_misses"
361
- ],
362
- success_mass=self.result.success_mass,
363
- start_order_masses=self.result.start_order_masses(),
364
- )
365
-
366
- def sample(self, *, rng: random.Random | int | None = None) -> tuple[Symbol, ...]:
367
- return self.result.sample(rng=rng)
368
-
369
- def sample_many(
370
- self,
371
- count: int,
372
- *,
373
- rng: random.Random | int | None = None,
374
- ) -> list[tuple[Symbol, ...]]:
375
- return [
376
- sequence
377
- for sequence, _orders in self.result.sample_many_with_orders(count, rng=rng)
378
- ]
379
-
380
- def sample_with_orders(self, *, rng: random.Random | int | None = None) -> GeneratedSequence:
381
- sequence, orders = self.result.sample_with_orders(rng=rng)
382
- return GeneratedSequence(sequence=sequence, orders=orders)
383
-
384
- def sample_many_with_orders(
385
- self,
386
- count: int,
387
- *,
388
- rng: random.Random | int | None = None,
389
- ) -> list[GeneratedSequence]:
390
- return [
391
- GeneratedSequence(sequence=sequence, orders=orders)
392
- for sequence, orders in self.result.sample_many_with_orders(count, rng=rng)
393
- ]
394
-
395
- def sample_with_trace(
396
- self,
397
- *,
398
- rng: random.Random | int | None = None,
399
- ) -> tuple[tuple[Symbol, ...], tuple[OrderSampleStep, ...]]:
400
- return self.result.sample_with_trace(rng=rng)
401
-
402
-
403
- @dataclass(frozen=True)
404
- class ConstrainedOrderStackPlan:
405
- """Prefix-independent constrained order-stack preparation.
406
-
407
- Keep this object around when the model, horizon, and constraints are fixed
408
- but callers need to sample from many different prefixes. ``for_prefix`` is
409
- cheap and returns the existing prefix-bound backend wrapper.
410
- """
411
-
412
- plan: OrderStackBPPlan | RegularOrderStackBPPlan
413
-
414
- @property
415
- def length(self) -> int:
416
- return self.plan.length
417
-
418
- @property
419
- def is_regular(self) -> bool:
420
- return isinstance(self.plan, RegularOrderStackBPPlan)
421
-
422
- def for_prefix(self, prefix: Sequence[Symbol]) -> ConstrainedOrderStackBackend:
423
- return ConstrainedOrderStackBackend(self.plan.for_prefix(prefix))
424
-
425
- def sample(
426
- self,
427
- *,
428
- prefix: Sequence[Symbol],
429
- rng: random.Random | int | None = None,
430
- ) -> tuple[Symbol, ...]:
431
- return self.for_prefix(prefix).sample(rng=rng)
432
-
433
- def sample_with_orders(
434
- self,
435
- *,
436
- prefix: Sequence[Symbol],
437
- rng: random.Random | int | None = None,
438
- ) -> GeneratedSequence:
439
- return self.for_prefix(prefix).sample_with_orders(rng=rng)
440
-
441
-
442
- @dataclass(frozen=True)
443
- class ConstrainedOrderStackSupportPlan:
444
- """Reusable hard-support plan for dynamic soft regular acceptors.
445
-
446
- The wrapped hard plan owns the compiled source/order-stack graphs and stable
447
- constraints. ``with_soft_acceptor`` creates a prefix-bound backend with fresh
448
- regular caches so changing soft weights or topology cannot reuse stale beta
449
- values.
450
- """
451
-
452
- plan: ConstrainedOrderStackPlan
453
-
454
- @property
455
- def length(self) -> int:
456
- return self.plan.length
457
-
458
- @property
459
- def is_regular(self) -> bool:
460
- return self.plan.is_regular
461
-
462
- def for_prefix(self, prefix: Sequence[Symbol]) -> ConstrainedOrderStackBackend:
463
- """Bind the hard-only support plan to a prefix."""
464
-
465
- return self.plan.for_prefix(prefix)
466
-
467
- def with_soft_acceptor(
468
- self,
469
- soft_acceptor: DFA | None,
470
- *,
471
- prefix: Sequence[Symbol],
472
- soft_start_acceptor_state: Hashable | None = None,
473
- ) -> ConstrainedOrderStackBackend:
474
- """Bind a dynamic soft acceptor on top of the cached hard support.
475
-
476
- Passing ``None`` samples from the hard support only. For a real soft
477
- acceptor, the returned backend has new regular transition-row and beta
478
- caches but reuses the source/order-stack graphs and stable masks.
479
- """
480
-
481
- if soft_acceptor is None:
482
- return self.for_prefix(prefix)
483
-
484
- lower_plan = self.plan.plan
485
- soft0 = (
486
- soft_acceptor.start_state
487
- if soft_start_acceptor_state is None
488
- else soft_start_acceptor_state
489
- )
490
- if isinstance(lower_plan, RegularOrderStackBPPlan):
491
- soft_plan = lower_plan.with_soft_acceptor(
492
- soft_acceptor,
493
- start_acceptor_state=(lower_plan.start_acceptor_state, soft0),
494
- )
495
- else:
496
- soft_plan = lower_plan.with_regular_acceptor(
497
- soft_acceptor,
498
- start_acceptor_state=soft0,
499
- )
500
- return ConstrainedOrderStackBackend(soft_plan.for_prefix(prefix))
501
-
502
-
503
- @dataclass(frozen=True)
504
- class UntilOrderStackBackend:
505
- """Prepared reusable first-hit order-stack sampler."""
506
-
507
- backends: tuple[ConstrainedOrderStackBackend, ...]
508
- length_weights: tuple[float, ...]
509
- min_length: int
510
- max_length: int
511
-
512
- def __post_init__(self) -> None:
513
- if not self.backends:
514
- raise ValueError("at least one feasible length backend is required")
515
- if len(self.backends) != len(self.length_weights):
516
- raise ValueError("backends and length_weights must have the same length")
517
- if any(weight < 0.0 for weight in self.length_weights):
518
- raise ValueError("length weights must be non-negative")
519
- if sum(self.length_weights) <= 0.0:
520
- raise ValueError("at least one length weight must be positive")
521
-
522
- @property
523
- def feasible_lengths(self) -> tuple[int, ...]:
524
- return tuple(backend.result.length for backend in self.backends)
525
-
526
- @property
527
- def diagnostics(self) -> UntilOrderStackDiagnostics:
528
- length_diagnostics = tuple(
529
- UntilLengthDiagnostics(
530
- length=backend.result.length,
531
- weight=weight,
532
- backend=backend.diagnostics,
533
- )
534
- for backend, weight in zip(self.backends, self.length_weights)
535
- )
536
- return UntilOrderStackDiagnostics(
537
- backend="order_stack_until",
538
- min_length=self.min_length,
539
- max_length=self.max_length,
540
- feasible_lengths=self.feasible_lengths,
541
- length_weights=tuple(
542
- (backend.result.length, weight)
543
- for backend, weight in zip(self.backends, self.length_weights)
544
- ),
545
- length_diagnostics=length_diagnostics,
546
- )
547
-
548
- def sample(self, *, rng: random.Random | int | None = None) -> tuple[Symbol, ...]:
549
- generator = _coerce_backend_rng(rng)
550
- return self._choose_backend(generator).sample(rng=generator)
551
-
552
- def sample_many(
553
- self,
554
- count: int,
555
- *,
556
- rng: random.Random | int | None = None,
557
- ) -> list[tuple[Symbol, ...]]:
558
- generator = _coerce_backend_rng(rng)
559
- return [self.sample(rng=generator) for _ in range(count)]
560
-
561
- def sample_with_orders(
562
- self,
563
- *,
564
- rng: random.Random | int | None = None,
565
- ) -> GeneratedSequence:
566
- generator = _coerce_backend_rng(rng)
567
- return self._choose_backend(generator).sample_with_orders(rng=generator)
568
-
569
- def sample_many_with_orders(
570
- self,
571
- count: int,
572
- *,
573
- rng: random.Random | int | None = None,
574
- ) -> list[GeneratedSequence]:
575
- generator = _coerce_backend_rng(rng)
576
- return [self.sample_with_orders(rng=generator) for _ in range(count)]
577
-
578
- def sample_with_trace(
579
- self,
580
- *,
581
- rng: random.Random | int | None = None,
582
- ) -> tuple[tuple[Symbol, ...], tuple[OrderSampleStep, ...]]:
583
- generator = _coerce_backend_rng(rng)
584
- return self._choose_backend(generator).sample_with_trace(rng=generator)
585
-
586
- def _choose_backend(self, rng: random.Random) -> ConstrainedOrderStackBackend:
587
- cumulative = _cumulative_weights(self.length_weights)
588
- index = bisect.bisect_left(cumulative, rng.random() * cumulative[-1])
589
- if index >= len(self.backends):
590
- index = len(self.backends) - 1
591
- return self.backends[index]
592
-
593
-
594
- def run_constrained_order_stack(
595
- model: OrderStackModel,
596
- constraints: ConstraintSet | None = None,
597
- *,
598
- length: int,
599
- prefix: Sequence[Symbol],
600
- policy: OrderPolicy | None = None,
601
- alphabet: Iterable[Symbol] | None = None,
602
- prefer_dense_forbidden: bool = True,
603
- allowed_forbidden_symbols: AllowedForbiddenSymbols | None = None,
604
- minimize_source_graphs: bool = False,
605
- ) -> OrderStackBPResult | RegularOrderStackBPResult:
606
- """Run constrained order-stack BP through the public constraint API.
607
-
608
- Purely positional constraints use the positional order-stack backend. Any
609
- regular component uses the regular backend with positional constraints kept
610
- as masks instead of being folded into the DFA state.
611
- """
612
-
613
- compiled = compile_constraints(
614
- constraints,
615
- length=length,
616
- alphabet=tuple(model.alphabet if alphabet is None else alphabet),
617
- prefer_dense_forbidden=prefer_dense_forbidden,
618
- )
619
- if compiled.regular_acceptor is None:
620
- return run_order_stack_bp(
621
- model,
622
- length=length,
623
- prefix=prefix,
624
- constraints=compiled.positional,
625
- allowed_forbidden_symbols=allowed_forbidden_symbols,
626
- policy=policy,
627
- minimize_source_graphs=minimize_source_graphs,
628
- )
629
- return run_order_stack_masked_dfa_bp(
630
- model,
631
- compiled.regular_acceptor,
632
- length=length,
633
- prefix=prefix,
634
- constraints=compiled.positional,
635
- allowed_forbidden_symbols=allowed_forbidden_symbols,
636
- policy=policy,
637
- minimize_source_graphs=minimize_source_graphs,
638
- )
639
-
640
-
641
- def prepare_constrained_order_stack_plan(
642
- model: OrderStackModel,
643
- constraints: ConstraintSet | None = None,
644
- *,
645
- length: int,
646
- policy: OrderPolicy | None = None,
647
- alphabet: Iterable[Symbol] | None = None,
648
- prefer_dense_forbidden: bool = True,
649
- allowed_forbidden_symbols: AllowedForbiddenSymbols | None = None,
650
- minimize_source_graphs: bool = False,
651
- ) -> ConstrainedOrderStackPlan:
652
- """Prepare reusable constrained order-stack BP without binding a prefix."""
653
-
654
- compiled = compile_constraints(
655
- constraints,
656
- length=length,
657
- alphabet=tuple(model.alphabet if alphabet is None else alphabet),
658
- prefer_dense_forbidden=prefer_dense_forbidden,
659
- )
660
- if compiled.regular_acceptor is None:
661
- return ConstrainedOrderStackPlan(
662
- prepare_order_stack_bp(
663
- model,
664
- length=length,
665
- constraints=compiled.positional,
666
- allowed_forbidden_symbols=allowed_forbidden_symbols,
667
- policy=policy,
668
- minimize_source_graphs=minimize_source_graphs,
669
- )
670
- )
671
- return ConstrainedOrderStackPlan(
672
- prepare_order_stack_masked_dfa_bp(
673
- model,
674
- compiled.regular_acceptor,
675
- length=length,
676
- constraints=compiled.positional,
677
- allowed_forbidden_symbols=allowed_forbidden_symbols,
678
- policy=policy,
679
- minimize_source_graphs=minimize_source_graphs,
680
- )
681
- )
682
-
683
-
684
- def prepare_constrained_order_stack_support_plan(
685
- model: OrderStackModel,
686
- constraints: ConstraintSet | None = None,
687
- *,
688
- length: int,
689
- policy: OrderPolicy | None = None,
690
- alphabet: Iterable[Symbol] | None = None,
691
- prefer_dense_forbidden: bool = True,
692
- allowed_forbidden_symbols: AllowedForbiddenSymbols | None = None,
693
- minimize_source_graphs: bool = False,
694
- ) -> ConstrainedOrderStackSupportPlan:
695
- """Prepare hard support once for repeated dynamic soft regular weighting."""
696
-
697
- return ConstrainedOrderStackSupportPlan(
698
- prepare_constrained_order_stack_plan(
699
- model,
700
- constraints,
701
- length=length,
702
- policy=policy,
703
- alphabet=alphabet,
704
- prefer_dense_forbidden=prefer_dense_forbidden,
705
- allowed_forbidden_symbols=allowed_forbidden_symbols,
706
- minimize_source_graphs=minimize_source_graphs,
707
- )
708
- )
709
-
710
-
711
- def prepare_constrained_order_stack(
712
- model: OrderStackModel,
713
- constraints: ConstraintSet | None = None,
714
- *,
715
- length: int,
716
- prefix: Sequence[Symbol],
717
- policy: OrderPolicy | None = None,
718
- alphabet: Iterable[Symbol] | None = None,
719
- prefer_dense_forbidden: bool = True,
720
- allowed_forbidden_symbols: AllowedForbiddenSymbols | None = None,
721
- minimize_source_graphs: bool = False,
722
- ) -> ConstrainedOrderStackBackend:
723
- """Compile constraints and return a reusable order-stack backend."""
724
-
725
- try:
726
- plan = prepare_constrained_order_stack_plan(
727
- model,
728
- constraints,
729
- length=length,
730
- policy=policy,
731
- alphabet=alphabet,
732
- prefer_dense_forbidden=prefer_dense_forbidden,
733
- allowed_forbidden_symbols=allowed_forbidden_symbols,
734
- minimize_source_graphs=minimize_source_graphs,
735
- )
736
- except ValueError as error:
737
- if "prefix-independent graph compilation" not in str(error):
738
- raise
739
- return ConstrainedOrderStackBackend(
740
- run_constrained_order_stack(
741
- model,
742
- constraints,
743
- length=length,
744
- prefix=prefix,
745
- policy=policy,
746
- alphabet=alphabet,
747
- prefer_dense_forbidden=prefer_dense_forbidden,
748
- allowed_forbidden_symbols=allowed_forbidden_symbols,
749
- minimize_source_graphs=minimize_source_graphs,
750
- )
751
- )
752
- return plan.for_prefix(prefix)
753
-
754
-
755
- def prepare_constrained_order_stack_plan_from_sequences(
756
- sequences: Iterable[Sequence[Symbol]],
757
- constraints: ConstraintSet | None = None,
758
- *,
759
- max_order: int,
760
- length: int,
761
- policy: OrderPolicy | None = None,
762
- start_symbol: Symbol | None = None,
763
- end_symbol: Symbol | None = None,
764
- alphabet: Iterable[Symbol] | None = None,
765
- prefer_dense_forbidden: bool = True,
766
- minimize_source_graphs: bool = False,
767
- ) -> ConstrainedOrderStackPlan:
768
- """Build an order-stack model from sequences and prepare a prefixless plan."""
769
-
770
- model = OrderStackModel.from_sequences(
771
- sequences,
772
- max_order=max_order,
773
- start_symbol=start_symbol,
774
- end_symbol=end_symbol,
775
- )
776
- return prepare_constrained_order_stack_plan(
777
- model,
778
- constraints,
779
- length=length,
780
- policy=policy,
781
- alphabet=alphabet,
782
- prefer_dense_forbidden=prefer_dense_forbidden,
783
- minimize_source_graphs=minimize_source_graphs,
784
- )
785
-
786
-
787
- def prepare_constrained_order_stack_support_plan_from_sequences(
788
- sequences: Iterable[Sequence[Symbol]],
789
- constraints: ConstraintSet | None = None,
790
- *,
791
- max_order: int,
792
- length: int,
793
- policy: OrderPolicy | None = None,
794
- start_symbol: Symbol | None = None,
795
- end_symbol: Symbol | None = None,
796
- alphabet: Iterable[Symbol] | None = None,
797
- prefer_dense_forbidden: bool = True,
798
- minimize_source_graphs: bool = False,
799
- ) -> ConstrainedOrderStackSupportPlan:
800
- """Build an order-stack model once and prepare reusable hard support."""
801
-
802
- return ConstrainedOrderStackSupportPlan(
803
- prepare_constrained_order_stack_plan_from_sequences(
804
- sequences,
805
- constraints,
806
- max_order=max_order,
807
- length=length,
808
- policy=policy,
809
- start_symbol=start_symbol,
810
- end_symbol=end_symbol,
811
- alphabet=alphabet,
812
- prefer_dense_forbidden=prefer_dense_forbidden,
813
- minimize_source_graphs=minimize_source_graphs,
814
- )
815
- )
816
-
817
-
818
- def prepare_until_order_stack(
819
- model: OrderStackModel,
820
- *,
821
- prefix: Sequence[Symbol],
822
- stop: Symbol | Iterable[Symbol] | Callable[[Symbol], bool],
823
- min_length: int = 1,
824
- max_length: int = 64,
825
- constraints: ConstraintSet | None = None,
826
- policy: OrderPolicy | None = None,
827
- alphabet: Iterable[Symbol] | None = None,
828
- prefer_dense_forbidden: bool = True,
829
- minimize_source_graphs: bool = False,
830
- ) -> UntilOrderStackBackend:
831
- """Prepare variable-length first-hit continuation for the order stack.
832
-
833
- The generated suffix has length in ``[min_length, max_length]``. The final
834
- emitted symbol satisfies ``stop`` and no earlier emitted symbol does.
835
- Lengths are sampled proportionally to the sum of each feasible backend's
836
- positive start-order masses, falling back to unit weight if only a success
837
- indicator is available.
838
- """
839
-
840
- if min_length < 1:
841
- raise ValueError("min_length must be at least 1")
842
- if max_length < min_length:
843
- raise ValueError("max_length must be greater than or equal to min_length")
844
- if not prefix:
845
- raise ValueError("order-stack BP requires a non-empty prefix")
846
-
847
- stop_predicate, stop_symbols = _coerce_stop_condition(
848
- stop,
849
- known_symbols=model.alphabet,
850
- )
851
- prepared: list[ConstrainedOrderStackBackend] = []
852
- weights: list[float] = []
853
-
854
- for length in range(min_length, max_length + 1):
855
- if not _constraints_compatible_with_length(constraints, length):
856
- continue
857
- first_hit = _first_hit_constraints(stop_predicate, length)
858
- combined = combine_constraints(constraints, first_hit)
859
- allowed_forbidden = _allowed_forbidden_stop_symbols(
860
- model,
861
- stop_predicate,
862
- stop_symbols,
863
- length,
864
- )
865
- backend = prepare_constrained_order_stack(
866
- model,
867
- combined,
868
- length=length,
869
- prefix=prefix,
870
- policy=policy,
871
- alphabet=alphabet,
872
- prefer_dense_forbidden=prefer_dense_forbidden,
873
- allowed_forbidden_symbols=allowed_forbidden,
874
- minimize_source_graphs=minimize_source_graphs,
875
- )
876
- weight = _length_weight(backend)
877
- if weight <= 0.0:
878
- continue
879
- prepared.append(backend)
880
- weights.append(weight)
881
-
882
- if not prepared:
883
- raise ValueError("No feasible first-hit continuation length satisfies the constraints.")
884
-
885
- return UntilOrderStackBackend(
886
- backends=tuple(prepared),
887
- length_weights=tuple(weights),
888
- min_length=min_length,
889
- max_length=max_length,
890
- )
891
-
892
-
893
- def prepare_until_end_order_stack(
894
- model: OrderStackModel,
895
- *,
896
- prefix: Sequence[Symbol],
897
- end_symbol: Symbol,
898
- min_length: int = 1,
899
- max_length: int = 64,
900
- constraints: ConstraintSet | None = None,
901
- policy: OrderPolicy | None = None,
902
- alphabet: Iterable[Symbol] | None = None,
903
- prefer_dense_forbidden: bool = True,
904
- minimize_source_graphs: bool = False,
905
- ) -> UntilOrderStackBackend:
906
- """Prepare first-hit continuation that stops at ``end_symbol``."""
907
-
908
- return prepare_until_order_stack(
909
- model,
910
- prefix=prefix,
911
- stop=end_symbol,
912
- min_length=min_length,
913
- max_length=max_length,
914
- constraints=constraints,
915
- policy=policy,
916
- alphabet=alphabet,
917
- prefer_dense_forbidden=prefer_dense_forbidden,
918
- minimize_source_graphs=minimize_source_graphs,
919
- )
920
-
921
-
922
- def prepare_constrained_order_stack_from_sequences(
923
- sequences: Iterable[Sequence[Symbol]],
924
- constraints: ConstraintSet | None = None,
925
- *,
926
- max_order: int,
927
- length: int,
928
- prefix: Sequence[Symbol],
929
- policy: OrderPolicy | None = None,
930
- start_symbol: Symbol | None = None,
931
- end_symbol: Symbol | None = None,
932
- alphabet: Iterable[Symbol] | None = None,
933
- prefer_dense_forbidden: bool = True,
934
- minimize_source_graphs: bool = False,
935
- ) -> ConstrainedOrderStackBackend:
936
- """Build an order-stack model from sequences and prepare a backend."""
937
-
938
- model = OrderStackModel.from_sequences(
939
- sequences,
940
- max_order=max_order,
941
- start_symbol=start_symbol,
942
- end_symbol=end_symbol,
943
- )
944
- return prepare_constrained_order_stack(
945
- model,
946
- constraints,
947
- length=length,
948
- prefix=prefix,
949
- policy=policy,
950
- alphabet=alphabet,
951
- prefer_dense_forbidden=prefer_dense_forbidden,
952
- minimize_source_graphs=minimize_source_graphs,
953
- )
954
-
955
-
956
- def _coerce_stop_condition(
957
- stop: Symbol | Iterable[Symbol] | Callable[[Symbol], bool],
958
- *,
959
- known_symbols: Iterable[Symbol] = (),
960
- ) -> tuple[Callable[[Symbol], bool], frozenset[Symbol] | None]:
961
- if callable(stop):
962
- return lambda symbol: bool(stop(symbol)), None
963
- known = frozenset(known_symbols)
964
- if isinstance(stop, (str, bytes)) or _is_known_symbol(stop, known):
965
- symbols = frozenset({stop})
966
- else:
967
- try:
968
- symbols = frozenset(stop) # type: ignore[arg-type]
969
- except TypeError:
970
- symbols = frozenset({stop}) # type: ignore[list-item]
971
- if not symbols:
972
- raise ValueError("stop symbol set must not be empty")
973
- return lambda symbol: symbol in symbols, symbols
974
-
975
-
976
- def _constraints_compatible_with_length(
977
- constraints: ConstraintSet | None,
978
- length: int,
979
- ) -> bool:
980
- if constraints is None:
981
- return True
982
- for position in constraints.positional:
983
- if position < 0:
984
- raise IndexError(f"constraint position {position} is outside length {length}")
985
- if position >= length:
986
- return False
987
- if constraints.meter is not None and len(constraints.meter.pattern) != length:
988
- return False
989
- cumulative = constraints.cumulative_meter
990
- if cumulative is not None and cumulative.length is not None and cumulative.length != length:
991
- return False
992
- return True
993
-
994
-
995
- def _is_known_symbol(value: object, known_symbols: frozenset[Symbol]) -> bool:
996
- if not isinstance(value, Hashable):
997
- return False
998
- try:
999
- return value in known_symbols
1000
- except TypeError:
1001
- return False
1002
-
1003
-
1004
- def _first_hit_constraints(
1005
- stop_predicate: Callable[[Symbol], bool],
1006
- length: int,
1007
- ) -> ConstraintSet:
1008
- positional: dict[int, Callable[[Symbol], bool]] = {
1009
- position: _negate_predicate(stop_predicate)
1010
- for position in range(length - 1)
1011
- }
1012
- positional[length - 1] = stop_predicate
1013
- return ConstraintSet(positional=positional)
1014
-
1015
-
1016
- def _negate_predicate(predicate: Callable[[Symbol], bool]) -> Callable[[Symbol], bool]:
1017
- return lambda symbol: not predicate(symbol)
1018
-
1019
-
1020
- def _allowed_forbidden_stop_symbols(
1021
- model: OrderStackModel,
1022
- stop_predicate: Callable[[Symbol], bool],
1023
- stop_symbols: frozenset[Symbol] | None,
1024
- length: int,
1025
- ) -> dict[int, frozenset[Symbol]]:
1026
- if stop_symbols is None:
1027
- allowed = frozenset(
1028
- symbol
1029
- for symbol in model.forbidden_symbols
1030
- if _predicate_accepts(stop_predicate, symbol)
1031
- )
1032
- else:
1033
- allowed = frozenset(symbol for symbol in stop_symbols if symbol in model.forbidden_symbols)
1034
- return {length - 1: allowed} if allowed else {}
1035
-
1036
-
1037
- def _predicate_accepts(predicate: Callable[[Symbol], bool], symbol: Symbol) -> bool:
1038
- try:
1039
- return bool(predicate(symbol))
1040
- except Exception:
1041
- return False
1042
-
1043
-
1044
- def _length_weight(backend: ConstrainedOrderStackBackend) -> float:
1045
- mass = sum(
1046
- max(0.0, float(start_mass))
1047
- for _order, start_mass in backend.result.start_order_masses()
1048
- )
1049
- if mass > 0.0:
1050
- return mass
1051
- return 1.0 if backend.result.success_mass > 0.0 else 0.0
1052
-
1053
-
1054
- def _coerce_backend_rng(rng: random.Random | int | None) -> random.Random:
1055
- if isinstance(rng, random.Random):
1056
- return rng
1057
- return random.Random(rng)
1058
-
1059
-
1060
- def _cumulative_weights(weights: Sequence[float]) -> tuple[float, ...]:
1061
- total = 0.0
1062
- cumulative: list[float] = []
1063
- for weight in weights:
1064
- total += float(weight)
1065
- cumulative.append(total)
1066
- return tuple(cumulative)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/brute_force.py DELETED
@@ -1,116 +0,0 @@
1
- """Tiny exhaustive checks for exactness tests."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections import defaultdict
6
- from itertools import product
7
- from typing import Hashable, Iterable, Mapping
8
-
9
- from .acceptors import DFA, transition_weight as regular_transition_weight
10
- from .context import Context, ContextGraph, Symbol, _as_context
11
-
12
-
13
- def brute_force_distribution(
14
- graph: ContextGraph,
15
- acceptor: DFA,
16
- *,
17
- length: int,
18
- alphabet: Iterable[Symbol] | None = None,
19
- start_context: Iterable[Symbol] | Context | None = None,
20
- start_acceptor_state: Hashable | None = None,
21
- ) -> dict[tuple[Symbol, ...], float]:
22
- """Enumerate all accepted length-``n`` strings and unnormalized masses."""
23
-
24
- if length < 0:
25
- raise ValueError("length must be non-negative")
26
-
27
- context0 = graph.start_state if start_context is None else _as_context(start_context)
28
- acceptor0 = acceptor.start_state if start_acceptor_state is None else start_acceptor_state
29
- masses: dict[tuple[Symbol, ...], float] = defaultdict(float)
30
-
31
- if alphabet is not None:
32
- for sequence in product(tuple(alphabet), repeat=length):
33
- context_state = context0
34
- acceptor_state = acceptor0
35
- probability = 1.0
36
- accepted = True
37
- for symbol in sequence:
38
- edge = next(
39
- (edge for edge in graph.outgoing(context_state) if edge.symbol == symbol),
40
- None,
41
- )
42
- if edge is None:
43
- accepted = False
44
- break
45
- next_acceptor_state = acceptor.next_state(acceptor_state, symbol)
46
- if next_acceptor_state is None:
47
- accepted = False
48
- break
49
- dfa_weight = regular_transition_weight(acceptor, acceptor_state, symbol)
50
- if dfa_weight <= 0.0:
51
- accepted = False
52
- break
53
- probability *= edge.probability * dfa_weight
54
- context_state = edge.next_state
55
- acceptor_state = next_acceptor_state
56
- if accepted and acceptor.is_accepting(acceptor_state) and probability > 0.0:
57
- masses[sequence] += probability
58
- return dict(masses)
59
-
60
- def visit(
61
- time: int,
62
- context_state: Context,
63
- acceptor_state: Hashable,
64
- prefix: tuple[Symbol, ...],
65
- probability: float,
66
- ) -> None:
67
- if time == length:
68
- if acceptor.is_accepting(acceptor_state):
69
- masses[prefix] += probability
70
- return
71
-
72
- for edge in graph.outgoing(context_state):
73
- next_acceptor_state = acceptor.next_state(acceptor_state, edge.symbol)
74
- if next_acceptor_state is None:
75
- continue
76
- dfa_weight = regular_transition_weight(acceptor, acceptor_state, edge.symbol)
77
- if dfa_weight <= 0.0:
78
- continue
79
- visit(
80
- time + 1,
81
- edge.next_state,
82
- next_acceptor_state,
83
- prefix + (edge.symbol,),
84
- probability * edge.probability * dfa_weight,
85
- )
86
-
87
- visit(0, context0, acceptor0, (), 1.0)
88
- return dict(masses)
89
-
90
-
91
- def brute_force_partition_function(
92
- graph: ContextGraph,
93
- acceptor: DFA,
94
- *,
95
- length: int,
96
- alphabet: Iterable[Symbol] | None = None,
97
- start_context: Iterable[Symbol] | Context | None = None,
98
- start_acceptor_state: Hashable | None = None,
99
- ) -> float:
100
- return sum(
101
- brute_force_distribution(
102
- graph,
103
- acceptor,
104
- length=length,
105
- alphabet=alphabet,
106
- start_context=start_context,
107
- start_acceptor_state=start_acceptor_state,
108
- ).values()
109
- )
110
-
111
-
112
- def conditional_distribution(masses: Mapping[tuple[Symbol, ...], float]) -> dict[tuple[Symbol, ...], float]:
113
- total = float(sum(masses.values()))
114
- if total <= 0.0:
115
- return {}
116
- return {sequence: mass / total for sequence, mass in masses.items()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/constraint_builders.py DELETED
@@ -1,238 +0,0 @@
1
- """Convenience builders for common fixed-horizon constraints."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable, Iterable, Mapping, Sequence
6
-
7
- from .constraints import ConstraintSet, CumulativeMeterConstraint, MeterConstraint
8
- from .context import Symbol
9
- from .positional_bp import PositionConstraint, _allows
10
-
11
-
12
- def combine_constraints(*constraints: ConstraintSet | None) -> ConstraintSet:
13
- """Merge constraint sets into one specification.
14
-
15
- Positional constraints at the same position are intersected. The current
16
- public ``ConstraintSet`` supports one meter and one cumulative-meter
17
- constraint; passing multiple distinct constraints of either kind raises.
18
- """
19
-
20
- positional: dict[int, PositionConstraint] = {}
21
- forbidden_substrings: list[Sequence[Symbol]] = []
22
- regular_acceptors = []
23
- meter = None
24
- cumulative_meter = None
25
-
26
- for spec in constraints:
27
- if spec is None:
28
- continue
29
- for position, constraint in spec.positional.items():
30
- if position in positional:
31
- positional[position] = _intersect_position_constraints(
32
- positional[position],
33
- constraint,
34
- )
35
- else:
36
- positional[position] = constraint
37
- forbidden_substrings.extend(spec.forbidden_substrings)
38
- regular_acceptors.extend(spec.regular_acceptors)
39
- if spec.meter is not None:
40
- if meter is not None and spec.meter != meter:
41
- raise ValueError("combine_constraints supports at most one meter constraint")
42
- meter = spec.meter
43
- if spec.cumulative_meter is not None:
44
- if cumulative_meter is not None and spec.cumulative_meter != cumulative_meter:
45
- raise ValueError(
46
- "combine_constraints supports at most one cumulative meter constraint"
47
- )
48
- cumulative_meter = spec.cumulative_meter
49
-
50
- return ConstraintSet(
51
- positional=positional,
52
- forbidden_substrings=tuple(forbidden_substrings),
53
- regular_acceptors=tuple(regular_acceptors),
54
- meter=meter,
55
- cumulative_meter=cumulative_meter,
56
- )
57
-
58
-
59
- def at_position(position: int, allowed: PositionConstraint | Symbol) -> ConstraintSet:
60
- """Constrain one zero-based generated position."""
61
-
62
- return ConstraintSet(positional={int(position): _position_constraint(allowed)})
63
-
64
-
65
- def final_symbol(symbol: Symbol, *, length: int) -> ConstraintSet:
66
- """Constrain the final generated symbol to one value."""
67
-
68
- return final_symbols({symbol}, length=length)
69
-
70
-
71
- def final_symbols(allowed: PositionConstraint | Symbol, *, length: int) -> ConstraintSet:
72
- """Constrain the final generated position."""
73
-
74
- if length <= 0:
75
- raise ValueError("length must be positive for a final-position constraint")
76
- return at_position(length - 1, allowed)
77
-
78
-
79
- def final_pitch_class(
80
- pitch_class: int,
81
- *,
82
- length: int,
83
- modulo: int = 12,
84
- symbol_to_pitch: Mapping[Symbol, int] | Callable[[Symbol], int] | None = None,
85
- ) -> ConstraintSet:
86
- """Constrain the final generated symbol by pitch class.
87
-
88
- By default the symbol itself is interpreted as an integer pitch. Richer
89
- symbol types can pass a mapping or callable through ``symbol_to_pitch``.
90
- """
91
-
92
- if modulo <= 0:
93
- raise ValueError("modulo must be positive")
94
- target = int(pitch_class) % int(modulo)
95
-
96
- def allows(symbol: Symbol) -> bool:
97
- pitch = _lookup(symbol_to_pitch, symbol)
98
- return int(pitch) % int(modulo) == target
99
-
100
- return final_symbols(allows, length=length)
101
-
102
-
103
- def avoid_copied_ngrams(reference: Sequence[Symbol], ngram_length: int) -> ConstraintSet:
104
- """Reject generated substrings copied from a reference sequence."""
105
-
106
- if ngram_length <= 0:
107
- raise ValueError("ngram_length must be positive")
108
- forbidden = tuple(
109
- dict.fromkeys(
110
- tuple(reference[index : index + ngram_length])
111
- for index in range(0, len(reference) - ngram_length + 1)
112
- )
113
- )
114
- return ConstraintSet(forbidden_substrings=forbidden)
115
-
116
-
117
- def meter_pattern(
118
- pattern: Sequence[object | Iterable[object] | None],
119
- symbol_to_meter: Mapping[Symbol, object] | Callable[[Symbol], object],
120
- *,
121
- name: str = "meter",
122
- ) -> ConstraintSet:
123
- """Constrain per-position meter/classes."""
124
-
125
- return ConstraintSet(
126
- meter=MeterConstraint(
127
- pattern=pattern,
128
- symbol_to_meter=symbol_to_meter,
129
- name=name,
130
- )
131
- )
132
-
133
-
134
- def cumulative_meter(
135
- cost: Mapping[Symbol, int] | Callable[[Symbol], int],
136
- predicate: Callable[[int, Symbol, int], bool] | None = None,
137
- *,
138
- length: int | None = None,
139
- max_cost: int | None = None,
140
- accept_costs: Iterable[int] | Callable[[int], bool] | None = None,
141
- end_symbol: Symbol | None = None,
142
- name: str = "cumulative_meter",
143
- ) -> ConstraintSet:
144
- """Constrain cumulative symbolic cost such as duration or beat position."""
145
-
146
- return ConstraintSet(
147
- cumulative_meter=CumulativeMeterConstraint(
148
- cost=cost,
149
- predicate=predicate,
150
- length=length,
151
- max_cost=max_cost,
152
- accept_costs=accept_costs,
153
- end_symbol=end_symbol,
154
- name=name,
155
- )
156
- )
157
-
158
-
159
- def padded_duration_total(
160
- total: int,
161
- *,
162
- length: int,
163
- pad_symbol: Symbol,
164
- symbol_to_duration: Mapping[Symbol, int] | Callable[[Symbol], int],
165
- allow_zero_duration_events: bool = False,
166
- name: str = "padded_duration_total",
167
- ) -> ConstraintSet:
168
- """Constrain generated duration with trailing PAD symbols.
169
-
170
- The generated sequence has fixed length, but its musical prefix may be
171
- shorter. The PAD symbol has duration zero, may appear only once ``total`` is
172
- reached, and is absorbing once emitted.
173
- """
174
-
175
- if total < 0:
176
- raise ValueError("total must be non-negative")
177
- if length < 0:
178
- raise ValueError("length must be non-negative")
179
- target = int(total)
180
-
181
- def duration_of(symbol: Symbol) -> int:
182
- if symbol == pad_symbol:
183
- return 0
184
- return _lookup(symbol_to_duration, symbol)
185
-
186
- def predicate(current_total: int, symbol: Symbol, one_based_position: int) -> bool:
187
- del one_based_position
188
- if symbol == pad_symbol:
189
- return current_total == target
190
- duration = duration_of(symbol)
191
- if duration == 0 and not allow_zero_duration_events:
192
- return False
193
- if current_total == target:
194
- return False
195
- return True
196
-
197
- return cumulative_meter(
198
- duration_of,
199
- predicate,
200
- length=length,
201
- max_cost=target,
202
- accept_costs={target},
203
- end_symbol=pad_symbol,
204
- name=name,
205
- )
206
-
207
-
208
- def _intersect_position_constraints(
209
- left: PositionConstraint,
210
- right: PositionConstraint,
211
- ) -> PositionConstraint:
212
- def allows(symbol: Symbol) -> bool:
213
- return _allows(left, symbol) and _allows(right, symbol)
214
-
215
- return allows
216
-
217
-
218
- def _position_constraint(allowed: PositionConstraint | Symbol) -> PositionConstraint:
219
- if callable(allowed):
220
- return allowed
221
- if isinstance(allowed, (str, bytes)):
222
- return {allowed}
223
- try:
224
- iter(allowed) # type: ignore[arg-type]
225
- except TypeError:
226
- return {allowed} # type: ignore[return-value]
227
- return allowed # type: ignore[return-value]
228
-
229
-
230
- def _lookup(
231
- mapping_or_callable: Mapping[Symbol, int] | Callable[[Symbol], int] | None,
232
- symbol: Symbol,
233
- ) -> int:
234
- if mapping_or_callable is None:
235
- return int(symbol)
236
- if isinstance(mapping_or_callable, Mapping):
237
- return int(mapping_or_callable[symbol])
238
- return int(mapping_or_callable(symbol))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/constraints.py DELETED
@@ -1,173 +0,0 @@
1
- """Public constraint specifications and compiler helpers.
2
-
3
- The classes in this module are intentionally project-agnostic: symbols can be
4
- pitches, durations, tokens, events, or any other hashable objects. The compiler
5
- keeps purely positional constraints as time-indexed masks and compiles regular
6
- constraints into deterministic acceptors.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from collections.abc import Callable, Iterable, Mapping, Sequence
12
- from dataclasses import dataclass, field
13
- from typing import Hashable
14
-
15
- from .acceptors import (
16
- DFA,
17
- all_of,
18
- cumulative_meter_acceptor,
19
- dense_forbidden_substring_acceptor,
20
- forbidden_substring_acceptor,
21
- meter_acceptor,
22
- )
23
- from .context import Symbol
24
- from .positional_bp import PositionConstraint, PositionConstraints
25
-
26
-
27
- MeterClass = Hashable
28
-
29
-
30
- @dataclass(frozen=True)
31
- class MeterConstraint:
32
- """Per-position meter/classes for emitted symbols.
33
-
34
- ``pattern`` entries may be a single class, an iterable of allowed classes,
35
- or ``None`` as a wildcard. ``symbol_to_meter`` maps each emitted symbol to
36
- its meter class.
37
- """
38
-
39
- pattern: Sequence[MeterClass | Iterable[MeterClass] | None]
40
- symbol_to_meter: Mapping[Symbol, MeterClass] | Callable[[Symbol], MeterClass]
41
- name: str = "meter"
42
-
43
-
44
- @dataclass(frozen=True)
45
- class CumulativeMeterConstraint:
46
- """Cumulative-cost meter predicate.
47
-
48
- The DFA state stores the cumulative cost before each emission. ``predicate``
49
- is called as ``predicate(total_cost_before, symbol, one_based_position)``.
50
- If ``length`` is omitted, the compiler uses the requested generation length.
51
- """
52
-
53
- cost: Mapping[Symbol, int] | Callable[[Symbol], int]
54
- predicate: Callable[[int, Symbol, int], bool] | None = None
55
- length: int | None = None
56
- max_cost: int | None = None
57
- accept_costs: Iterable[int] | Callable[[int], bool] | None = None
58
- end_symbol: Symbol | None = None
59
- name: str = "cumulative_meter"
60
-
61
-
62
- @dataclass(frozen=True)
63
- class ConstraintSet:
64
- """Constraints for fixed-horizon symbolic generation.
65
-
66
- Positional constraints are represented as masks and do not inflate regular
67
- DFA state. Regular constraints are compiled into acceptors and intersected.
68
- """
69
-
70
- positional: PositionConstraints = field(default_factory=dict)
71
- forbidden_substrings: Iterable[Sequence[Symbol]] = field(default_factory=tuple)
72
- regular_acceptors: Sequence[DFA] = field(default_factory=tuple)
73
- meter: MeterConstraint | None = None
74
- cumulative_meter: CumulativeMeterConstraint | None = None
75
-
76
-
77
- @dataclass(frozen=True)
78
- class CompiledConstraints:
79
- """Compiled constraints consumed by BP backends."""
80
-
81
- positional: dict[int, PositionConstraint]
82
- regular_acceptor: DFA | None = None
83
-
84
- @property
85
- def has_regular(self) -> bool:
86
- return self.regular_acceptor is not None
87
-
88
-
89
- def compile_constraints(
90
- constraints: ConstraintSet | None,
91
- *,
92
- length: int,
93
- alphabet: Iterable[Symbol],
94
- prefer_dense_forbidden: bool = True,
95
- ) -> CompiledConstraints:
96
- """Compile public constraint specs into masks plus an optional DFA."""
97
-
98
- if length < 0:
99
- raise ValueError("length must be non-negative")
100
- spec = constraints or ConstraintSet()
101
- alphabet_tuple = tuple(alphabet)
102
-
103
- positional = dict(spec.positional)
104
- _validate_positions(positional, length)
105
-
106
- regulars: list[DFA] = list(spec.regular_acceptors)
107
- forbidden = tuple(tuple(pattern) for pattern in spec.forbidden_substrings)
108
- if forbidden:
109
- if prefer_dense_forbidden:
110
- regulars.append(
111
- dense_forbidden_substring_acceptor(
112
- forbidden,
113
- alphabet=alphabet_tuple,
114
- name="dense_forbidden_substring",
115
- )
116
- )
117
- else:
118
- regulars.append(
119
- forbidden_substring_acceptor(
120
- forbidden,
121
- alphabet=alphabet_tuple,
122
- name="forbidden_substring",
123
- )
124
- )
125
-
126
- if spec.meter is not None:
127
- if len(spec.meter.pattern) != length:
128
- raise ValueError("meter pattern length must match generation length")
129
- regulars.append(
130
- meter_acceptor(
131
- spec.meter.pattern,
132
- spec.meter.symbol_to_meter,
133
- alphabet=alphabet_tuple,
134
- name=spec.meter.name,
135
- )
136
- )
137
-
138
- if spec.cumulative_meter is not None:
139
- cumulative = spec.cumulative_meter
140
- if cumulative.length is not None and cumulative.length != length:
141
- raise ValueError("cumulative meter length must match generation length")
142
- regulars.append(
143
- cumulative_meter_acceptor(
144
- cumulative.length if cumulative.length is not None else length,
145
- cumulative.cost,
146
- cumulative.predicate,
147
- alphabet=alphabet_tuple,
148
- max_cost=cumulative.max_cost,
149
- accept_costs=cumulative.accept_costs,
150
- end_symbol=cumulative.end_symbol,
151
- name=cumulative.name,
152
- )
153
- )
154
-
155
- regular_acceptor = _combine_regulars(regulars)
156
- return CompiledConstraints(
157
- positional=positional,
158
- regular_acceptor=regular_acceptor,
159
- )
160
-
161
-
162
- def _combine_regulars(regulars: Sequence[DFA]) -> DFA | None:
163
- if not regulars:
164
- return None
165
- if len(regulars) == 1:
166
- return regulars[0]
167
- return all_of(*regulars, name="constraint_set")
168
-
169
-
170
- def _validate_positions(constraints: Mapping[int, PositionConstraint], length: int) -> None:
171
- for position in constraints:
172
- if position < 0 or position >= length:
173
- raise IndexError(f"constraint position {position} is outside length {length}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/context.py DELETED
@@ -1,397 +0,0 @@
1
- """Sparse variable-order context graphs."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections import Counter, defaultdict
6
- from dataclasses import dataclass
7
- import math
8
- from typing import Hashable, Iterable, Mapping, Sequence
9
-
10
- Symbol = Hashable
11
- Context = tuple[Symbol, ...]
12
-
13
-
14
- @dataclass(frozen=True)
15
- class Edge:
16
- """A labeled probabilistic transition in a context graph."""
17
-
18
- symbol: Symbol
19
- probability: float
20
- next_state: Context
21
- order_weights: tuple[tuple[int, float], ...] = ()
22
-
23
-
24
- def _as_context(value: Iterable[Symbol] | Context | None) -> Context:
25
- if value is None:
26
- return ()
27
- if isinstance(value, tuple):
28
- return value
29
- return tuple(value)
30
-
31
-
32
- class ContextGraph:
33
- """Sparse context graph induced by a variable-order/backoff model.
34
-
35
- Nodes are canonical contexts represented as tuples of symbols. Each outgoing
36
- edge emits one symbol and moves to the canonical suffix context after that
37
- emission.
38
- """
39
-
40
- def __init__(
41
- self,
42
- edges_by_state: Mapping[Iterable[Symbol] | Context, Sequence[Edge]],
43
- *,
44
- start_state: Iterable[Symbol] | Context = (),
45
- max_order: int | None = None,
46
- alphabet: Iterable[Symbol] | None = None,
47
- validate: bool = True,
48
- ) -> None:
49
- normalized: dict[Context, tuple[Edge, ...]] = {}
50
- states: set[Context] = {_as_context(start_state)}
51
- emitted: set[Symbol] = set(alphabet or ())
52
-
53
- for raw_state, raw_edges in edges_by_state.items():
54
- state = _as_context(raw_state)
55
- edges = tuple(
56
- Edge(
57
- edge.symbol,
58
- float(edge.probability),
59
- _as_context(edge.next_state),
60
- tuple((int(order), float(weight)) for order, weight in edge.order_weights),
61
- )
62
- for edge in raw_edges
63
- )
64
- normalized[state] = edges
65
- states.add(state)
66
- for edge in edges:
67
- states.add(edge.next_state)
68
- emitted.add(edge.symbol)
69
-
70
- self._edges = normalized
71
- self.start_state = _as_context(start_state)
72
- self.states = frozenset(states)
73
- self.alphabet = frozenset(emitted)
74
- self._alias_to_state: dict[Context, Context] = {}
75
- self._continuation_counts: dict[Context, dict[Symbol, float]] = {}
76
- self._state_supports: dict[Context, float] = {}
77
- self.max_order = (
78
- max_order
79
- if max_order is not None
80
- else max((len(state) for state in self.states), default=0)
81
- )
82
-
83
- if self.max_order < 0:
84
- raise ValueError("max_order must be non-negative")
85
- if validate:
86
- self._validate()
87
-
88
- @classmethod
89
- def from_counts(
90
- cls,
91
- continuation_counts: Mapping[Iterable[Symbol] | Context, Mapping[Symbol, int | float]],
92
- *,
93
- max_order: int | None = None,
94
- start_state: Iterable[Symbol] | Context = (),
95
- ) -> "ContextGraph":
96
- """Build a graph by normalizing explicit continuation counts."""
97
-
98
- contexts = {_as_context(context) for context in continuation_counts}
99
- contexts.add(_as_context(start_state))
100
- if max_order is None:
101
- max_order = max((len(context) for context in contexts), default=0)
102
-
103
- edges_by_state: dict[Context, list[Edge]] = {}
104
- count_metadata: dict[Context, dict[Symbol, float]] = {}
105
- state_supports: dict[Context, float] = {}
106
- for raw_context, counts in continuation_counts.items():
107
- context = _as_context(raw_context)
108
- total = float(sum(counts.values()))
109
- if total <= 0.0:
110
- raise ValueError(f"context {context!r} has no positive continuation mass")
111
- state_supports[context] = total
112
- count_metadata[context] = {
113
- symbol: float(count)
114
- for symbol, count in counts.items()
115
- if count > 0
116
- }
117
- edges_by_state[context] = [
118
- Edge(symbol, float(count) / total, cls._canon(context, symbol, contexts, max_order))
119
- for symbol, count in counts.items()
120
- if count > 0
121
- ]
122
- graph = cls(
123
- edges_by_state,
124
- start_state=start_state,
125
- max_order=max_order,
126
- validate=True,
127
- )
128
- graph._continuation_counts = count_metadata
129
- graph._state_supports = state_supports
130
- return graph
131
-
132
- @classmethod
133
- def from_probabilities(
134
- cls,
135
- probabilities: Mapping[Iterable[Symbol] | Context, Mapping[Symbol, float]],
136
- *,
137
- max_order: int | None = None,
138
- start_state: Iterable[Symbol] | Context = (),
139
- ) -> "ContextGraph":
140
- """Build a graph from explicit conditional probabilities."""
141
-
142
- contexts = {_as_context(context) for context in probabilities}
143
- contexts.add(_as_context(start_state))
144
- if max_order is None:
145
- max_order = max((len(context) for context in contexts), default=0)
146
-
147
- edges_by_state: dict[Context, list[Edge]] = {}
148
- continuation_counts: dict[Context, dict[Symbol, float]] = {}
149
- state_supports: dict[Context, float] = {}
150
- for raw_context, probs in probabilities.items():
151
- context = _as_context(raw_context)
152
- continuation_counts[context] = {
153
- symbol: float(prob)
154
- for symbol, prob in probs.items()
155
- if prob > 0.0
156
- }
157
- state_supports[context] = float(sum(continuation_counts[context].values()))
158
- edges_by_state[context] = [
159
- Edge(symbol, float(prob), cls._canon(context, symbol, contexts, max_order))
160
- for symbol, prob in probs.items()
161
- if prob > 0.0
162
- ]
163
- graph = cls(
164
- edges_by_state,
165
- start_state=start_state,
166
- max_order=max_order,
167
- validate=True,
168
- )
169
- graph._continuation_counts = continuation_counts
170
- graph._state_supports = state_supports
171
- return graph
172
-
173
- @classmethod
174
- def from_sequences(
175
- cls,
176
- sequences: Iterable[Sequence[Symbol]],
177
- *,
178
- max_order: int,
179
- start_state: Iterable[Symbol] | Context = (),
180
- ) -> "ContextGraph":
181
- """Estimate normalized continuation counts for all suffix contexts.
182
-
183
- For every token position, the implementation records the continuation
184
- from each suffix context of orders 0..max_order available before that
185
- token. This gives a sparse variable-order graph whose transitions are
186
- normalized continuation counts at each observed context.
187
- """
188
-
189
- if max_order < 0:
190
- raise ValueError("max_order must be non-negative")
191
-
192
- return cls.from_weighted_sequences(
193
- ((1.0, sequence) for sequence in sequences),
194
- max_order=max_order,
195
- start_state=start_state,
196
- )
197
-
198
- @classmethod
199
- def from_weighted_sequences(
200
- cls,
201
- weighted_sequences: Iterable[tuple[int | float, Sequence[Symbol]]],
202
- *,
203
- max_order: int,
204
- start_state: Iterable[Symbol] | Context = (),
205
- ) -> "ContextGraph":
206
- """Estimate continuation counts from a weighted sequence multiset."""
207
-
208
- if max_order < 0:
209
- raise ValueError("max_order must be non-negative")
210
-
211
- counts: dict[Context, Counter[Symbol]] = defaultdict(Counter)
212
-
213
- for weight, sequence in weighted_sequences:
214
- if weight <= 0:
215
- continue
216
- tokens = tuple(sequence)
217
- for index, symbol in enumerate(tokens):
218
- order_limit = min(max_order, index)
219
- for order in range(order_limit + 1):
220
- context = tokens[index - order : index] if order else ()
221
- counts[context][symbol] += weight
222
-
223
- return cls.from_counts(counts, max_order=max_order, start_state=start_state)
224
-
225
- @classmethod
226
- def from_backoff_sequences(
227
- cls,
228
- sequences: Iterable[Sequence[Symbol]],
229
- *,
230
- max_order: int,
231
- backoff_weight: float = 0.25,
232
- start_state: Iterable[Symbol] | Context = (),
233
- ) -> "ContextGraph":
234
- """Build a sparse graph with explicit lower-order backoff support.
235
-
236
- Each context receives a weighted mixture of normalized continuation
237
- distributions from itself and its suffixes. This is useful for exact
238
- experiments where a strict longest-context MLE model would make every
239
- ``K+1``-gram transition copied from training by construction.
240
- """
241
-
242
- if max_order < 0:
243
- raise ValueError("max_order must be non-negative")
244
- if not 0.0 <= backoff_weight <= 1.0:
245
- raise ValueError("backoff_weight must be in [0, 1]")
246
-
247
- counts: dict[Context, Counter[Symbol]] = defaultdict(Counter)
248
- for sequence in sequences:
249
- tokens = tuple(sequence)
250
- for index, symbol in enumerate(tokens):
251
- order_limit = min(max_order, index)
252
- for order in range(order_limit + 1):
253
- context = tokens[index - order : index] if order else ()
254
- counts[context][symbol] += 1
255
-
256
- contexts = set(counts)
257
- contexts.add(_as_context(start_state))
258
- edges_by_state: dict[Context, list[Edge]] = {}
259
- continuation_counts: dict[Context, dict[Symbol, float]] = {}
260
- state_supports: dict[Context, float] = {}
261
-
262
- for context in contexts:
263
- scores: Counter[Symbol] = Counter()
264
- order_scores: dict[Symbol, Counter[int]] = defaultdict(Counter)
265
- context_order = len(context)
266
- for order in range(context_order, -1, -1):
267
- suffix = context[-order:] if order else ()
268
- if suffix not in counts:
269
- continue
270
- total = float(sum(counts[suffix].values()))
271
- if total <= 0.0:
272
- continue
273
- weight = backoff_weight ** (context_order - order)
274
- for symbol, count in counts[suffix].items():
275
- contribution = weight * (float(count) / total)
276
- scores[symbol] += contribution
277
- order_scores[symbol][order] += contribution
278
-
279
- total_score = float(sum(scores.values()))
280
- if total_score <= 0.0:
281
- continue
282
- state_supports[context] = total_score
283
- continuation_counts[context] = {
284
- symbol: float(score)
285
- for symbol, score in scores.items()
286
- if score > 0.0
287
- }
288
- edges_by_state[context] = [
289
- Edge(
290
- symbol,
291
- score / total_score,
292
- cls._canon(context, symbol, contexts, max_order),
293
- tuple(
294
- sorted(
295
- (
296
- (order, contribution / score)
297
- for order, contribution in order_scores[symbol].items()
298
- ),
299
- reverse=True,
300
- )
301
- ),
302
- )
303
- for symbol, score in scores.items()
304
- if score > 0.0
305
- ]
306
-
307
- graph = cls(
308
- edges_by_state,
309
- start_state=start_state,
310
- max_order=max_order,
311
- validate=True,
312
- )
313
- graph._continuation_counts = continuation_counts
314
- graph._state_supports = state_supports
315
- return graph
316
-
317
- @staticmethod
318
- def _canon(
319
- context: Context,
320
- symbol: Symbol,
321
- known_contexts: set[Context] | frozenset[Context],
322
- max_order: int,
323
- ) -> Context:
324
- candidate = context + (symbol,)
325
- limit = min(max_order, len(candidate))
326
- for order in range(limit, -1, -1):
327
- suffix = candidate[-order:] if order else ()
328
- if suffix in known_contexts:
329
- return suffix
330
- return ()
331
-
332
- def canonical_context(self, context: Iterable[Symbol] | Context, symbol: Symbol) -> Context:
333
- """Return the longest known suffix after emitting ``symbol``."""
334
-
335
- candidate = _as_context(context) + (symbol,)
336
- limit = min(self.max_order, len(candidate))
337
- for order in range(limit, -1, -1):
338
- suffix = candidate[-order:] if order else ()
339
- state = self._resolve_state(suffix)
340
- if state in self.states:
341
- return state
342
- return ()
343
-
344
- def outgoing(self, state: Iterable[Symbol] | Context) -> tuple[Edge, ...]:
345
- """Outgoing edges for a context, or an empty tuple for dead contexts."""
346
-
347
- return self._edges.get(self._resolve_state(state), ())
348
-
349
- def _resolve_state(self, state: Iterable[Symbol] | Context) -> Context:
350
- context = _as_context(state)
351
- return self._alias_to_state.get(context, context)
352
-
353
- def edge_count(self) -> int:
354
- return sum(len(edges) for edges in self._edges.values())
355
-
356
- def probability(self, sequence: Sequence[Symbol], *, start_state: Iterable[Symbol] | Context | None = None) -> float:
357
- """Unconstrained probability of a sequence under the context graph."""
358
-
359
- state = self.start_state if start_state is None else _as_context(start_state)
360
- prob = 1.0
361
- for symbol in sequence:
362
- edge = next((edge for edge in self.outgoing(state) if edge.symbol == symbol), None)
363
- if edge is None:
364
- return 0.0
365
- prob *= edge.probability
366
- state = edge.next_state
367
- return prob
368
-
369
- def _validate(self) -> None:
370
- if self.start_state not in self.states:
371
- raise ValueError("start_state must be present in graph states")
372
- if self.max_order < max((len(state) for state in self.states), default=0):
373
- raise ValueError("max_order is smaller than at least one context state")
374
-
375
- for state, edges in self._edges.items():
376
- seen_symbols: set[Symbol] = set()
377
- total = 0.0
378
- for edge in edges:
379
- if edge.symbol in seen_symbols:
380
- raise ValueError(f"state {state!r} has duplicate edge for symbol {edge.symbol!r}")
381
- seen_symbols.add(edge.symbol)
382
- if edge.next_state not in self.states:
383
- raise ValueError(f"edge from {state!r} points to unknown state {edge.next_state!r}")
384
- if not math.isfinite(edge.probability) or edge.probability < 0.0:
385
- raise ValueError(f"invalid probability {edge.probability!r} on edge {edge!r}")
386
- order_total = 0.0
387
- for order, weight in edge.order_weights:
388
- if order < 0:
389
- raise ValueError(f"invalid negative order {order!r} on edge {edge!r}")
390
- if not math.isfinite(weight) or weight < 0.0:
391
- raise ValueError(f"invalid order weight {weight!r} on edge {edge!r}")
392
- order_total += weight
393
- if edge.order_weights and not math.isclose(order_total, 1.0, rel_tol=1e-9, abs_tol=1e-9):
394
- raise ValueError(f"order weights on edge {edge!r} sum to {order_total}, not 1")
395
- total += edge.probability
396
- if edges and not math.isclose(total, 1.0, rel_tol=1e-9, abs_tol=1e-9):
397
- raise ValueError(f"outgoing probabilities from {state!r} sum to {total}, not 1")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/continuator.py DELETED
@@ -1,238 +0,0 @@
1
- """Continuator-style facade over the reusable constrained BP backend.
2
-
3
- This module does not import Continuator. It provides a small compatibility
4
- surface with Continuator vocabulary so an external Continuator project can swap
5
- its generation backend to ``vo_regular_bp`` with minimal glue code.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from collections.abc import Callable, Iterable, Mapping, Sequence
11
- from typing import TypeVar
12
-
13
- from .adapters import (
14
- EventCodec,
15
- EventOrderStackBackend,
16
- EventOrderStackPlan,
17
- infer_symbol_to_event as infer_symbol_decoder,
18
- prepare_constrained_order_stack_plan_from_events,
19
- )
20
- from .constraint_builders import (
21
- cumulative_meter,
22
- final_pitch_class,
23
- meter_pattern,
24
- padded_duration_total,
25
- )
26
- from .constraints import ConstraintSet
27
- from .context import Symbol
28
- from .order_stack_bp import OrderPolicy, SingletonAvoidingBackoffPolicy
29
-
30
-
31
- EventT = TypeVar("EventT")
32
-
33
-
34
- def prepare_continuation_backend(
35
- training_events: Iterable[Sequence[EventT]],
36
- *,
37
- prefix: Sequence[EventT],
38
- horizon: int,
39
- max_order: int,
40
- constraints: ConstraintSet | None = None,
41
- event_to_symbol: Callable[[EventT], Symbol] | None = None,
42
- symbol_to_event: Mapping[Symbol, EventT] | Callable[[Symbol], EventT] | None = None,
43
- infer_decoder: bool = True,
44
- strict_decoder: bool = True,
45
- policy: OrderPolicy | None = None,
46
- start_event: EventT | None = None,
47
- end_event: EventT | None = None,
48
- alphabet: Iterable[Symbol] | None = None,
49
- prefer_dense_forbidden: bool = True,
50
- minimize_source_graphs: bool = False,
51
- ) -> EventOrderStackBackend[EventT]:
52
- """Prepare a constrained order-stack backend in Continuator vocabulary.
53
-
54
- ``training_events`` and ``prefix`` are project-level event objects.
55
- ``event_to_symbol`` maps those events to hashable symbols consumed by the
56
- backend. If no explicit ``symbol_to_event`` decoder is supplied, the
57
- function can infer one from the training material when symbols identify
58
- unique events.
59
- """
60
-
61
- if horizon < 0:
62
- raise ValueError("horizon must be non-negative")
63
- material = tuple(tuple(sequence) for sequence in training_events)
64
- prefix_tuple = tuple(prefix)
65
-
66
- codec = _build_codec(
67
- material,
68
- prefix_tuple,
69
- event_to_symbol=event_to_symbol,
70
- symbol_to_event=symbol_to_event,
71
- infer_decoder=infer_decoder,
72
- strict_decoder=strict_decoder,
73
- )
74
- active_policy = policy if policy is not None else SingletonAvoidingBackoffPolicy()
75
-
76
- plan = prepare_constrained_order_stack_plan_from_events(
77
- material,
78
- constraints,
79
- codec=codec,
80
- max_order=max_order,
81
- length=horizon,
82
- policy=active_policy,
83
- start_event=start_event,
84
- end_event=end_event,
85
- alphabet=alphabet,
86
- prefer_dense_forbidden=prefer_dense_forbidden,
87
- minimize_source_graphs=minimize_source_graphs,
88
- )
89
- return plan.for_prefix(prefix_tuple)
90
-
91
-
92
- def prepare_continuation_plan(
93
- training_events: Iterable[Sequence[EventT]],
94
- *,
95
- horizon: int,
96
- max_order: int,
97
- constraints: ConstraintSet | None = None,
98
- event_to_symbol: Callable[[EventT], Symbol] | None = None,
99
- symbol_to_event: Mapping[Symbol, EventT] | Callable[[Symbol], EventT] | None = None,
100
- infer_decoder: bool = True,
101
- strict_decoder: bool = True,
102
- policy: OrderPolicy | None = None,
103
- start_event: EventT | None = None,
104
- end_event: EventT | None = None,
105
- alphabet: Iterable[Symbol] | None = None,
106
- prefer_dense_forbidden: bool = True,
107
- minimize_source_graphs: bool = False,
108
- ) -> EventOrderStackPlan[EventT]:
109
- """Prepare a reusable Continuator-shaped plan without binding a prefix."""
110
-
111
- if horizon < 0:
112
- raise ValueError("horizon must be non-negative")
113
- material = tuple(tuple(sequence) for sequence in training_events)
114
- codec = _build_codec(
115
- material,
116
- (),
117
- event_to_symbol=event_to_symbol,
118
- symbol_to_event=symbol_to_event,
119
- infer_decoder=infer_decoder,
120
- strict_decoder=strict_decoder,
121
- )
122
- active_policy = policy if policy is not None else SingletonAvoidingBackoffPolicy()
123
-
124
- return prepare_constrained_order_stack_plan_from_events(
125
- material,
126
- constraints,
127
- codec=codec,
128
- max_order=max_order,
129
- length=horizon,
130
- policy=active_policy,
131
- start_event=start_event,
132
- end_event=end_event,
133
- alphabet=alphabet,
134
- prefer_dense_forbidden=prefer_dense_forbidden,
135
- minimize_source_graphs=minimize_source_graphs,
136
- )
137
-
138
-
139
- def final_pitch_class_constraint(
140
- pitch_class: int,
141
- *,
142
- horizon: int,
143
- modulo: int = 12,
144
- symbol_to_pitch: Mapping[Symbol, int] | Callable[[Symbol], int] | None = None,
145
- ) -> ConstraintSet:
146
- """Constrain the final generated symbol to a pitch class."""
147
-
148
- return final_pitch_class(
149
- pitch_class,
150
- length=horizon,
151
- modulo=modulo,
152
- symbol_to_pitch=symbol_to_pitch,
153
- )
154
-
155
-
156
- def duration_total_constraint(
157
- total: int,
158
- *,
159
- horizon: int | None = None,
160
- symbol_to_duration: Mapping[Symbol, int] | Callable[[Symbol], int],
161
- max_total: int | None = None,
162
- name: str = "duration_total",
163
- ) -> ConstraintSet:
164
- """Constrain the total generated duration/cost."""
165
-
166
- if total < 0:
167
- raise ValueError("total must be non-negative")
168
- return cumulative_meter(
169
- symbol_to_duration,
170
- length=horizon,
171
- max_cost=total if max_total is None else max_total,
172
- accept_costs={total},
173
- name=name,
174
- )
175
-
176
-
177
- def padded_duration_total_constraint(
178
- total: int,
179
- *,
180
- horizon: int,
181
- pad_symbol: Symbol,
182
- symbol_to_duration: Mapping[Symbol, int] | Callable[[Symbol], int],
183
- allow_zero_duration_events: bool = False,
184
- name: str = "padded_duration_total",
185
- ) -> ConstraintSet:
186
- """Constrain generated duration with trailing PAD symbols."""
187
-
188
- return padded_duration_total(
189
- total,
190
- length=horizon,
191
- pad_symbol=pad_symbol,
192
- symbol_to_duration=symbol_to_duration,
193
- allow_zero_duration_events=allow_zero_duration_events,
194
- name=name,
195
- )
196
-
197
-
198
- def meter_cycle_constraint(
199
- cycle: Sequence[object | Iterable[object] | None],
200
- *,
201
- horizon: int,
202
- symbol_to_meter: Mapping[Symbol, object] | Callable[[Symbol], object],
203
- offset: int = 0,
204
- name: str = "meter_cycle",
205
- ) -> ConstraintSet:
206
- """Constrain generated positions by a repeating meter/class cycle."""
207
-
208
- if horizon < 0:
209
- raise ValueError("horizon must be non-negative")
210
- if not cycle:
211
- raise ValueError("cycle must not be empty")
212
- pattern = tuple(cycle[(offset + position) % len(cycle)] for position in range(horizon))
213
- return meter_pattern(pattern, symbol_to_meter, name=name)
214
-
215
-
216
- def _build_codec(
217
- training_events: Sequence[Sequence[EventT]],
218
- prefix: Sequence[EventT],
219
- *,
220
- event_to_symbol: Callable[[EventT], Symbol] | None,
221
- symbol_to_event: Mapping[Symbol, EventT] | Callable[[Symbol], EventT] | None,
222
- infer_decoder: bool,
223
- strict_decoder: bool,
224
- ) -> EventCodec[EventT]:
225
- if event_to_symbol is None:
226
- codec = EventCodec.identity()
227
- if symbol_to_event is not None:
228
- return codec.with_decoder(symbol_to_event) # type: ignore[arg-type, return-value]
229
- return codec # type: ignore[return-value]
230
-
231
- decoder = symbol_to_event
232
- if decoder is None and infer_decoder:
233
- decoder = infer_symbol_decoder(
234
- tuple(training_events) + (tuple(prefix),),
235
- event_to_symbol,
236
- strict=strict_decoder,
237
- )
238
- return EventCodec(event_to_symbol=event_to_symbol, symbol_to_event=decoder)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/experimental.py DELETED
@@ -1,986 +0,0 @@
1
- """Experimental source-model transformations.
2
-
3
- The functions in this module are explicit modeling operations. They may change
4
- the source distribution, unlike exact minimization in :mod:`vo_regular_bp.minimization`.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from collections import Counter, defaultdict
10
- from collections.abc import Callable, Hashable, Mapping
11
- from dataclasses import dataclass
12
- import math
13
- import time
14
- from typing import Any
15
-
16
- from .context import Context, ContextGraph, Edge, Symbol
17
-
18
-
19
- @dataclass(frozen=True)
20
- class AlergiaMergeMetadata:
21
- """Diagnostics for an experimental ALERGIA-style source merge."""
22
-
23
- method: str
24
- alpha: float
25
- min_support: float
26
- recursive: bool
27
- original_state_count: int
28
- merged_state_count: int
29
- original_edge_count: int
30
- merged_edge_count: int
31
- state_to_class: dict[Context, int]
32
- classes: tuple[tuple[Context, ...], ...]
33
- conflicting_symbol_destinations: int = 0
34
- symbol_projection: str | None = None
35
- transition_projection: str | None = None
36
- projection_kind: str = "identity"
37
-
38
- @property
39
- def state_compression_ratio(self) -> float:
40
- return _ratio(self.original_state_count, self.merged_state_count)
41
-
42
- @property
43
- def edge_compression_ratio(self) -> float:
44
- return _ratio(self.original_edge_count, self.merged_edge_count)
45
-
46
- def as_dict(self) -> dict[str, object]:
47
- return {
48
- "method": self.method,
49
- "alpha": self.alpha,
50
- "min_support": self.min_support,
51
- "recursive": self.recursive,
52
- "original_state_count": self.original_state_count,
53
- "merged_state_count": self.merged_state_count,
54
- "original_edge_count": self.original_edge_count,
55
- "merged_edge_count": self.merged_edge_count,
56
- "state_compression_ratio": self.state_compression_ratio,
57
- "edge_compression_ratio": self.edge_compression_ratio,
58
- "conflicting_symbol_destinations": self.conflicting_symbol_destinations,
59
- "symbol_projection": self.symbol_projection,
60
- "transition_projection": self.transition_projection,
61
- "projection_kind": self.projection_kind,
62
- }
63
-
64
-
65
- @dataclass(frozen=True)
66
- class AlergiaFixedOrderMergeMetadata:
67
- """Diagnostics for an experimental ALERGIA merge of one fixed-order graph."""
68
-
69
- method: str
70
- order: int
71
- alpha: float
72
- min_support: float
73
- recursive: bool
74
- original_state_count: int
75
- merged_state_count: int
76
- original_edge_count: int
77
- merged_edge_count: int
78
- state_to_class: dict[Context, int]
79
- classes: tuple[tuple[Context, ...], ...]
80
- conflicting_symbol_destinations: int = 0
81
- conflicting_edge_orders: int = 0
82
- symbol_projection: str | None = None
83
- transition_projection: str | None = None
84
- projection_kind: str = "identity"
85
- merge_time_seconds: float = 0.0
86
-
87
- @property
88
- def state_compression_ratio(self) -> float:
89
- return _ratio(self.original_state_count, self.merged_state_count)
90
-
91
- @property
92
- def edge_compression_ratio(self) -> float:
93
- return _ratio(self.original_edge_count, self.merged_edge_count)
94
-
95
- def as_dict(self) -> dict[str, object]:
96
- return {
97
- "method": self.method,
98
- "order": self.order,
99
- "alpha": self.alpha,
100
- "min_support": self.min_support,
101
- "recursive": self.recursive,
102
- "original_state_count": self.original_state_count,
103
- "merged_state_count": self.merged_state_count,
104
- "original_edge_count": self.original_edge_count,
105
- "merged_edge_count": self.merged_edge_count,
106
- "state_compression_ratio": self.state_compression_ratio,
107
- "edge_compression_ratio": self.edge_compression_ratio,
108
- "conflicting_symbol_destinations": self.conflicting_symbol_destinations,
109
- "conflicting_edge_orders": self.conflicting_edge_orders,
110
- "symbol_projection": self.symbol_projection,
111
- "transition_projection": self.transition_projection,
112
- "projection_kind": self.projection_kind,
113
- "merge_time_seconds": self.merge_time_seconds,
114
- }
115
-
116
-
117
- @dataclass(frozen=True)
118
- class AlergiaOrderStackMergeMetadata:
119
- """Diagnostics for an experimental ALERGIA-merged order-stack model."""
120
-
121
- method: str
122
- alpha: float
123
- min_support: float
124
- recursive: bool
125
- orders: dict[int, AlergiaFixedOrderMergeMetadata]
126
-
127
- @property
128
- def original_state_count(self) -> int:
129
- return sum(metadata.original_state_count for metadata in self.orders.values())
130
-
131
- @property
132
- def merged_state_count(self) -> int:
133
- return sum(metadata.merged_state_count for metadata in self.orders.values())
134
-
135
- @property
136
- def original_edge_count(self) -> int:
137
- return sum(metadata.original_edge_count for metadata in self.orders.values())
138
-
139
- @property
140
- def merged_edge_count(self) -> int:
141
- return sum(metadata.merged_edge_count for metadata in self.orders.values())
142
-
143
- @property
144
- def merge_time_seconds(self) -> float:
145
- return sum(metadata.merge_time_seconds for metadata in self.orders.values())
146
-
147
- @property
148
- def state_compression_ratio(self) -> float:
149
- return _ratio(self.original_state_count, self.merged_state_count)
150
-
151
- @property
152
- def edge_compression_ratio(self) -> float:
153
- return _ratio(self.original_edge_count, self.merged_edge_count)
154
-
155
- def as_dict(self) -> dict[str, object]:
156
- return {
157
- "method": self.method,
158
- "alpha": self.alpha,
159
- "min_support": self.min_support,
160
- "recursive": self.recursive,
161
- "original_state_count": self.original_state_count,
162
- "merged_state_count": self.merged_state_count,
163
- "original_edge_count": self.original_edge_count,
164
- "merged_edge_count": self.merged_edge_count,
165
- "state_compression_ratio": self.state_compression_ratio,
166
- "edge_compression_ratio": self.edge_compression_ratio,
167
- "merge_time_seconds": self.merge_time_seconds,
168
- "orders": {
169
- order: metadata.as_dict()
170
- for order, metadata in sorted(self.orders.items())
171
- },
172
- }
173
-
174
-
175
- @dataclass(frozen=True)
176
- class _ProjectionCache:
177
- counts: dict[Context, dict[Hashable, float]]
178
- dominant_next: dict[Context, dict[Hashable, Context]]
179
-
180
-
181
- _PairMemo = dict[tuple[Context, Context], tuple[bool, tuple[tuple[Context, Context], ...]]]
182
-
183
-
184
- def alergia_merge(
185
- graph: ContextGraph,
186
- *,
187
- alpha: float = 0.01,
188
- min_support: int | float = 10,
189
- recursive: bool = True,
190
- symbol_projection: Callable[[Symbol], Hashable] | None = None,
191
- transition_projection: Callable[[Context, Symbol, Edge], Hashable] | None = None,
192
- ) -> ContextGraph:
193
- """Return an ALERGIA-like approximate source-state merge.
194
-
195
- This function acts only on the unconstrained source graph. ``symbol_projection``
196
- lets a caller compare emitted symbols through a feature map. For
197
- context-aware semantics, ``transition_projection`` can instead map
198
- ``(state, symbol, edge)`` to a comparison feature, for example an interval
199
- from the current context to the emitted symbol. If both projections are
200
- supplied, ``transition_projection`` takes precedence. The returned graph
201
- still emits concrete symbols and remains a regular :class:`ContextGraph`,
202
- so existing BP remains exact with respect to the new merged source model.
203
- """
204
-
205
- if alpha <= 0.0 or alpha >= 1.0:
206
- raise ValueError("alpha must be in (0, 1)")
207
- if min_support < 0:
208
- raise ValueError("min_support must be non-negative")
209
-
210
- counts = _state_counts(graph)
211
- project = _transition_projector(
212
- symbol_projection=symbol_projection,
213
- transition_projection=transition_projection,
214
- )
215
- projection_cache = _precompute_projection_cache(graph, counts, project)
216
- supports = {
217
- state: float(sum(symbol_counts.values()))
218
- for state, symbol_counts in counts.items()
219
- }
220
- states = tuple(sorted(graph.states, key=_context_sort_key))
221
- classes = _ClassRegistry(states)
222
- pair_memo: _PairMemo = {}
223
-
224
- for state in states:
225
- if supports.get(state, 0.0) < min_support:
226
- continue
227
- for root in classes.roots():
228
- if classes.find(state) == root:
229
- break
230
- members = classes.members(root)
231
- compatible, pairs = _compatible_with_class(
232
- graph,
233
- counts,
234
- supports,
235
- state,
236
- members,
237
- alpha=float(alpha),
238
- min_support=float(min_support),
239
- recursive=recursive,
240
- projection_cache=projection_cache,
241
- pair_memo=pair_memo,
242
- )
243
- if compatible:
244
- classes.union(state, members[0])
245
- for left, right in pairs:
246
- classes.union(left, right)
247
- break
248
-
249
- return _build_merged_graph(
250
- graph,
251
- counts,
252
- classes.union_find,
253
- alpha=float(alpha),
254
- min_support=float(min_support),
255
- recursive=recursive,
256
- symbol_projection_name=_projection_name(symbol_projection),
257
- transition_projection_name=_projection_name(transition_projection),
258
- projection_kind=_projection_kind(symbol_projection, transition_projection),
259
- )
260
-
261
-
262
- def alergia_metadata(graph: object) -> (
263
- AlergiaMergeMetadata
264
- | AlergiaFixedOrderMergeMetadata
265
- | AlergiaOrderStackMergeMetadata
266
- | None
267
- ):
268
- """Return ALERGIA metadata if ``graph`` was produced by an experimental merge."""
269
-
270
- return getattr(graph, "alergia_metadata", None)
271
-
272
-
273
- def alergia_merge_fixed_order_graph(
274
- graph: object,
275
- *,
276
- alpha: float = 0.01,
277
- min_support: int | float = 10,
278
- recursive: bool = True,
279
- symbol_projection: Callable[[Symbol], Hashable] | None = None,
280
- transition_projection: Callable[[Context, Symbol, Edge], Hashable] | None = None,
281
- continuation_counts: Mapping[Context, Mapping[Symbol, int | float]] | None = None,
282
- ) -> object:
283
- """Return an experimental ALERGIA-merged fixed-order order-stack graph.
284
-
285
- The merge is applied to one unconstrained fixed-order source graph. The
286
- result is still a ``FixedOrderContextGraph`` and can be used by the existing
287
- order-stack BP backends without a special sampling path.
288
- """
289
-
290
- started = time.perf_counter()
291
- context_graph = _fixed_order_to_context_graph(
292
- graph,
293
- continuation_counts=continuation_counts,
294
- )
295
- merged_context_graph = alergia_merge(
296
- context_graph,
297
- alpha=alpha,
298
- min_support=min_support,
299
- recursive=recursive,
300
- symbol_projection=symbol_projection,
301
- transition_projection=transition_projection,
302
- )
303
- merged_graph, conflicting_edge_orders = _context_graph_to_fixed_order_graph(
304
- merged_context_graph,
305
- graph,
306
- )
307
- source_metadata = alergia_metadata(merged_context_graph)
308
- if not isinstance(source_metadata, AlergiaMergeMetadata):
309
- raise RuntimeError("missing ContextGraph ALERGIA metadata")
310
- merged_graph.alergia_metadata = AlergiaFixedOrderMergeMetadata(
311
- method="alergia_fixed_order",
312
- order=int(graph.order),
313
- alpha=float(alpha),
314
- min_support=float(min_support),
315
- recursive=recursive,
316
- original_state_count=source_metadata.original_state_count,
317
- merged_state_count=source_metadata.merged_state_count,
318
- original_edge_count=source_metadata.original_edge_count,
319
- merged_edge_count=source_metadata.merged_edge_count,
320
- state_to_class=source_metadata.state_to_class,
321
- classes=source_metadata.classes,
322
- conflicting_symbol_destinations=source_metadata.conflicting_symbol_destinations,
323
- conflicting_edge_orders=conflicting_edge_orders,
324
- symbol_projection=source_metadata.symbol_projection,
325
- transition_projection=source_metadata.transition_projection,
326
- projection_kind=source_metadata.projection_kind,
327
- merge_time_seconds=time.perf_counter() - started,
328
- )
329
- return merged_graph
330
-
331
-
332
- def alergia_merge_order_stack_model(
333
- model: object,
334
- *,
335
- alpha: float = 0.01,
336
- min_support: int | float = 10,
337
- recursive: bool = True,
338
- symbol_projection: Callable[[Symbol], Hashable] | None = None,
339
- transition_projection: Callable[[Context, Symbol, Edge], Hashable] | None = None,
340
- ) -> "AlergiaOrderStackModel":
341
- """Return an experimental order-stack model with ALERGIA-merged sources.
342
-
343
- Each fixed-order graph is merged independently. The returned model exposes
344
- the same ``compile_graph(order)`` interface used by the existing order-stack
345
- BP preparation functions, so sampling APIs do not need a special path.
346
- """
347
-
348
- graphs = {}
349
- metadata_by_order = {}
350
- for order in range(1, int(model.max_order) + 1):
351
- graph = model.compile_graph(order)
352
- merged_graph = alergia_merge_fixed_order_graph(
353
- graph,
354
- alpha=alpha,
355
- min_support=min_support,
356
- recursive=recursive,
357
- symbol_projection=symbol_projection,
358
- transition_projection=transition_projection,
359
- continuation_counts=_fixed_order_counts_from_model(model, graph),
360
- )
361
- graphs[order] = merged_graph
362
- metadata = alergia_metadata(merged_graph)
363
- if not isinstance(metadata, AlergiaFixedOrderMergeMetadata):
364
- raise RuntimeError("missing fixed-order ALERGIA metadata")
365
- metadata_by_order[order] = metadata
366
-
367
- return AlergiaOrderStackModel(
368
- base_model=model,
369
- graphs=graphs,
370
- metadata=AlergiaOrderStackMergeMetadata(
371
- method="alergia_order_stack",
372
- alpha=float(alpha),
373
- min_support=float(min_support),
374
- recursive=recursive,
375
- orders=metadata_by_order,
376
- ),
377
- )
378
-
379
-
380
- class AlergiaOrderStackModel:
381
- """Experimental order-stack model backed by ALERGIA-merged fixed-order graphs."""
382
-
383
- def __init__(
384
- self,
385
- *,
386
- base_model: object,
387
- graphs: dict[int, object],
388
- metadata: AlergiaOrderStackMergeMetadata,
389
- ) -> None:
390
- self.base_model = base_model
391
- self.max_order = int(base_model.max_order)
392
- self.forbidden_symbols = frozenset(getattr(base_model, "forbidden_symbols", ()))
393
- self.alphabet = frozenset(getattr(base_model, "alphabet", ()))
394
- self._graphs = dict(graphs)
395
- self.alergia_metadata = metadata
396
-
397
- def compile_graph(self, order: int) -> object:
398
- if order < 1 or order > self.max_order:
399
- raise ValueError(f"order must be between 1 and {self.max_order}")
400
- return self._graphs[int(order)]
401
-
402
- def compile_graphs_for_plan(self, *, length: int) -> dict[int, object]:
403
- return dict(self._graphs)
404
-
405
-
406
- def _compatible_with_class(
407
- graph: ContextGraph,
408
- counts: dict[Context, dict[Symbol, float]],
409
- supports: dict[Context, float],
410
- state: Context,
411
- members: list[Context],
412
- *,
413
- alpha: float,
414
- min_support: float,
415
- recursive: bool,
416
- projection_cache: _ProjectionCache,
417
- pair_memo: _PairMemo,
418
- ) -> tuple[bool, list[tuple[Context, Context]]]:
419
- pairs: list[tuple[Context, Context]] = []
420
- for member in members:
421
- compatible, member_pairs = _compatible_pair(
422
- graph,
423
- counts,
424
- supports,
425
- state,
426
- member,
427
- alpha=alpha,
428
- min_support=min_support,
429
- recursive=recursive,
430
- projection_cache=projection_cache,
431
- pair_memo=pair_memo,
432
- seen=set(),
433
- )
434
- if not compatible:
435
- return False, []
436
- pairs.extend(member_pairs)
437
- return True, pairs
438
-
439
-
440
- def _compatible_pair(
441
- graph: ContextGraph,
442
- counts: dict[Context, dict[Symbol, float]],
443
- supports: dict[Context, float],
444
- left: Context,
445
- right: Context,
446
- *,
447
- alpha: float,
448
- min_support: float,
449
- recursive: bool,
450
- projection_cache: _ProjectionCache,
451
- pair_memo: _PairMemo,
452
- seen: set[tuple[Context, Context]],
453
- ) -> tuple[bool, list[tuple[Context, Context]]]:
454
- if left == right:
455
- return True, []
456
- pair = (left, right) if _context_sort_key(left) <= _context_sort_key(right) else (right, left)
457
- if pair in seen:
458
- return True, []
459
- cache_positive = not seen
460
- cached = pair_memo.get(pair)
461
- if cached is not None:
462
- compatible, pairs = cached
463
- if not compatible or cache_positive:
464
- return compatible, list(pairs)
465
- seen.add(pair)
466
-
467
- left_counts = counts.get(left, {})
468
- right_counts = counts.get(right, {})
469
- left_support = supports.get(left, 0.0)
470
- right_support = supports.get(right, 0.0)
471
- if left_support == 0.0 and right_support == 0.0 and not left_counts and not right_counts:
472
- return _cache_pair_result(pair_memo, pair, True, [pair], cache_positive)
473
- if left_support <= 0.0 or right_support <= 0.0:
474
- return _cache_pair_result(pair_memo, pair, False, [], cache_positive)
475
- if left_support < min_support or right_support < min_support:
476
- return _cache_pair_result(pair_memo, pair, False, [], cache_positive)
477
-
478
- left_projected = projection_cache.counts.get(left, {})
479
- right_projected = projection_cache.counts.get(right, {})
480
- bound = _hoeffding_bound(left_support, right_support, alpha)
481
- for projected_symbol in set(left_projected) | set(right_projected):
482
- left_probability = left_projected.get(projected_symbol, 0.0) / left_support
483
- right_probability = right_projected.get(projected_symbol, 0.0) / right_support
484
- if abs(left_probability - right_probability) > bound:
485
- return _cache_pair_result(pair_memo, pair, False, [], cache_positive)
486
-
487
- pairs = [pair]
488
- if recursive:
489
- left_next = projection_cache.dominant_next.get(left, {})
490
- right_next = projection_cache.dominant_next.get(right, {})
491
- for projected_symbol in set(left_next) & set(right_next):
492
- compatible, child_pairs = _compatible_pair(
493
- graph,
494
- counts,
495
- supports,
496
- left_next[projected_symbol],
497
- right_next[projected_symbol],
498
- alpha=alpha,
499
- min_support=min_support,
500
- recursive=True,
501
- projection_cache=projection_cache,
502
- pair_memo=pair_memo,
503
- seen=seen,
504
- )
505
- if not compatible:
506
- return _cache_pair_result(pair_memo, pair, False, [], cache_positive)
507
- pairs.extend(child_pairs)
508
- return _cache_pair_result(pair_memo, pair, True, pairs, cache_positive)
509
-
510
-
511
- def _build_merged_graph(
512
- graph: ContextGraph,
513
- counts: dict[Context, dict[Symbol, float]],
514
- union: "_UnionFind",
515
- *,
516
- alpha: float,
517
- min_support: float,
518
- recursive: bool,
519
- symbol_projection_name: str | None,
520
- transition_projection_name: str | None,
521
- projection_kind: str,
522
- ) -> ContextGraph:
523
- states = tuple(sorted(graph.states, key=_context_sort_key))
524
- root_to_members: dict[Context, list[Context]] = defaultdict(list)
525
- for state in states:
526
- root_to_members[union.find(state)].append(state)
527
-
528
- roots = tuple(
529
- sorted(
530
- root_to_members,
531
- key=lambda root: _context_sort_key(root_to_members[root][0]),
532
- )
533
- )
534
- root_to_class = {root: class_id for class_id, root in enumerate(roots)}
535
- representative_by_root = {
536
- root: tuple(sorted(members, key=_context_sort_key))[0]
537
- for root, members in root_to_members.items()
538
- }
539
-
540
- edges_by_state: dict[Context, list[Edge]] = {}
541
- merged_counts: dict[Context, dict[Symbol, float]] = {}
542
- merged_supports: dict[Context, float] = {}
543
- conflicting_symbol_destinations = 0
544
-
545
- for root in roots:
546
- representative = representative_by_root[root]
547
- symbol_counts: Counter[Symbol] = Counter()
548
- destination_counts: dict[Symbol, Counter[Context]] = defaultdict(Counter)
549
- order_scores: dict[Symbol, Counter[int]] = defaultdict(Counter)
550
-
551
- for member in root_to_members[root]:
552
- member_counts = counts.get(member, {})
553
- for edge in graph.outgoing(member):
554
- count = member_counts.get(edge.symbol, 0.0)
555
- if count <= 0.0:
556
- continue
557
- symbol_counts[edge.symbol] += count
558
- destination_counts[edge.symbol][union.find(edge.next_state)] += count
559
- for order, weight in edge.order_weights:
560
- order_scores[edge.symbol][int(order)] += count * float(weight)
561
-
562
- total = float(sum(symbol_counts.values()))
563
- merged_supports[representative] = total
564
- merged_counts[representative] = {
565
- symbol: float(count)
566
- for symbol, count in symbol_counts.items()
567
- if count > 0.0
568
- }
569
- if total <= 0.0:
570
- edges_by_state[representative] = []
571
- continue
572
-
573
- state_edges: list[Edge] = []
574
- for symbol, count in sorted(symbol_counts.items(), key=lambda item: _hashable_key(item[0])):
575
- destinations = destination_counts[symbol]
576
- if len(destinations) > 1:
577
- conflicting_symbol_destinations += 1
578
- destination_root = max(
579
- destinations,
580
- key=lambda candidate: (destinations[candidate], repr(candidate)),
581
- )
582
- order_weights = ()
583
- if order_scores[symbol]:
584
- order_total = float(sum(order_scores[symbol].values()))
585
- if order_total > 0.0:
586
- order_weights = tuple(
587
- sorted(
588
- (
589
- (order, score / order_total)
590
- for order, score in order_scores[symbol].items()
591
- ),
592
- reverse=True,
593
- )
594
- )
595
- state_edges.append(
596
- Edge(
597
- symbol=symbol,
598
- probability=float(count) / total,
599
- next_state=representative_by_root[destination_root],
600
- order_weights=order_weights,
601
- )
602
- )
603
- edges_by_state[representative] = state_edges
604
-
605
- start_state = representative_by_root[union.find(graph._resolve_state(graph.start_state))]
606
- merged = ContextGraph(
607
- edges_by_state,
608
- start_state=start_state,
609
- max_order=graph.max_order,
610
- alphabet=graph.alphabet,
611
- validate=True,
612
- )
613
-
614
- alias_to_state = {
615
- state: representative_by_root[union.find(state)]
616
- for state in states
617
- }
618
- for alias, resolved in getattr(graph, "_alias_to_state", {}).items():
619
- alias_to_state[alias] = representative_by_root[union.find(resolved)]
620
- merged._alias_to_state = alias_to_state
621
- merged._continuation_counts = merged_counts
622
- merged._state_supports = merged_supports
623
-
624
- state_to_class = {
625
- state: root_to_class[union.find(state)]
626
- for state in states
627
- }
628
- classes = tuple(
629
- tuple(sorted(root_to_members[root], key=_context_sort_key))
630
- for root in roots
631
- )
632
- merged.alergia_metadata = AlergiaMergeMetadata(
633
- method="alergia",
634
- alpha=alpha,
635
- min_support=min_support,
636
- recursive=recursive,
637
- original_state_count=len(graph.states),
638
- merged_state_count=len(merged.states),
639
- original_edge_count=graph.edge_count(),
640
- merged_edge_count=merged.edge_count(),
641
- state_to_class=state_to_class,
642
- classes=classes,
643
- conflicting_symbol_destinations=conflicting_symbol_destinations,
644
- symbol_projection=symbol_projection_name,
645
- transition_projection=transition_projection_name,
646
- projection_kind=projection_kind,
647
- )
648
- return merged
649
-
650
-
651
- def _fixed_order_to_context_graph(
652
- graph: object,
653
- *,
654
- continuation_counts: Mapping[Context, Mapping[Symbol, int | float]] | None,
655
- ) -> ContextGraph:
656
- contexts = tuple(graph.contexts)
657
- edges_by_state: dict[Context, list[Edge]] = {}
658
- count_metadata: dict[Context, dict[Symbol, float]] = {}
659
- state_supports: dict[Context, float] = {}
660
-
661
- for state_id, context in enumerate(contexts):
662
- raw_counts = continuation_counts.get(context) if continuation_counts is not None else None
663
- support = (
664
- float(sum(count for count in raw_counts.values() if count > 0))
665
- if raw_counts is not None
666
- else 1.0
667
- )
668
- if support <= 0.0 and graph.outgoing[state_id]:
669
- support = 1.0
670
- state_counts: dict[Symbol, float] = {}
671
- state_edges: list[Edge] = []
672
- for edge in graph.outgoing[state_id]:
673
- count = (
674
- float(raw_counts.get(edge.symbol, edge.probability * support))
675
- if raw_counts is not None
676
- else edge.probability * support
677
- )
678
- if count > 0.0:
679
- state_counts[edge.symbol] = count
680
- state_edges.append(
681
- Edge(
682
- symbol=edge.symbol,
683
- probability=edge.probability,
684
- next_state=contexts[edge.dst],
685
- order_weights=((int(edge.order), 1.0),),
686
- )
687
- )
688
- edges_by_state[context] = state_edges
689
- count_metadata[context] = state_counts
690
- state_supports[context] = float(sum(state_counts.values()))
691
-
692
- start_state = contexts[0] if contexts else ()
693
- context_graph = ContextGraph(
694
- edges_by_state,
695
- start_state=start_state,
696
- max_order=int(graph.order),
697
- validate=True,
698
- )
699
- alias_to_state = {
700
- alias: contexts[state_id]
701
- for alias, state_id in graph.context_to_id.items()
702
- if state_id < len(contexts)
703
- }
704
- for state_id, aliases in enumerate(getattr(graph, "state_aliases", ())):
705
- if state_id >= len(contexts):
706
- continue
707
- for alias in aliases:
708
- alias_to_state[alias] = contexts[state_id]
709
- context_graph._alias_to_state = alias_to_state
710
- context_graph._continuation_counts = count_metadata
711
- context_graph._state_supports = state_supports
712
- return context_graph
713
-
714
-
715
- def _context_graph_to_fixed_order_graph(
716
- merged: ContextGraph,
717
- source_graph: object,
718
- ) -> tuple[object, int]:
719
- from .order_stack_bp import FixedOrderContextGraph, StackEdge
720
-
721
- metadata = alergia_metadata(merged)
722
- if not isinstance(metadata, AlergiaMergeMetadata):
723
- raise RuntimeError("missing ContextGraph ALERGIA metadata")
724
-
725
- fixed = FixedOrderContextGraph(int(source_graph.order))
726
- representatives = [class_members[0] for class_members in metadata.classes]
727
- fixed.contexts = representatives
728
- fixed.context_to_id = {
729
- context: state_id
730
- for state_id, context in enumerate(representatives)
731
- }
732
- fixed.outgoing = [[] for _context in representatives]
733
-
734
- aliases: list[set[Context]] = [set(class_members) for class_members in metadata.classes]
735
- for alias, resolved in getattr(merged, "_alias_to_state", {}).items():
736
- state_id = fixed.context_to_id.get(resolved)
737
- if state_id is None:
738
- continue
739
- fixed.context_to_id[alias] = state_id
740
- aliases[state_id].add(alias)
741
- fixed.state_aliases = tuple(
742
- tuple(sorted(group, key=_context_sort_key))
743
- for group in aliases
744
- )
745
-
746
- conflicting_edge_orders = 0
747
- for src, context in enumerate(fixed.contexts):
748
- for edge in merged.outgoing(context):
749
- order, has_conflict = _dominant_order(edge.order_weights, int(source_graph.order))
750
- if has_conflict:
751
- conflicting_edge_orders += 1
752
- fixed.outgoing[src].append(
753
- StackEdge(
754
- src=src,
755
- dst=fixed.context_to_id[edge.next_state],
756
- symbol=edge.symbol,
757
- probability=edge.probability,
758
- order=order,
759
- )
760
- )
761
- return fixed, conflicting_edge_orders
762
-
763
-
764
- def _fixed_order_counts_from_model(
765
- model: object,
766
- graph: object,
767
- ) -> dict[Context, dict[Symbol, float]] | None:
768
- model_counts = getattr(model, "counts", None)
769
- longest_available_suffix = getattr(model, "longest_available_suffix", None)
770
- if not isinstance(model_counts, Mapping) or longest_available_suffix is None:
771
- return None
772
-
773
- result: dict[Context, dict[Symbol, float]] = {}
774
- for state_id, context in enumerate(graph.contexts):
775
- suffix = longest_available_suffix(context, max_order=int(graph.order))
776
- suffix_counts = model_counts.get(suffix, {}) if suffix is not None else {}
777
- state_counts = {
778
- edge.symbol: float(suffix_counts.get(edge.symbol, 0.0))
779
- for edge in graph.outgoing[state_id]
780
- if suffix_counts.get(edge.symbol, 0.0) > 0.0
781
- }
782
- if not state_counts and graph.outgoing[state_id]:
783
- state_counts = {
784
- edge.symbol: float(edge.probability)
785
- for edge in graph.outgoing[state_id]
786
- if edge.probability > 0.0
787
- }
788
- result[context] = state_counts
789
- return result
790
-
791
-
792
- def _dominant_order(
793
- order_weights: tuple[tuple[int, float], ...],
794
- default_order: int,
795
- ) -> tuple[int, bool]:
796
- if not order_weights:
797
- return default_order, False
798
- order, _weight = max(order_weights, key=lambda item: (item[1], item[0]))
799
- return int(order), len(order_weights) > 1
800
-
801
-
802
- def _state_counts(graph: ContextGraph) -> dict[Context, dict[Symbol, float]]:
803
- metadata = getattr(graph, "_continuation_counts", {})
804
- result: dict[Context, dict[Symbol, float]] = {}
805
- supports = getattr(graph, "_state_supports", {})
806
- for state in graph.states:
807
- if state in metadata:
808
- result[state] = dict(metadata[state])
809
- continue
810
- support = float(supports.get(state, 0.0))
811
- edges = graph.outgoing(state)
812
- if support <= 0.0 and edges:
813
- support = 1.0
814
- result[state] = {
815
- edge.symbol: edge.probability * support
816
- for edge in edges
817
- if edge.probability > 0.0
818
- }
819
- return result
820
-
821
-
822
- def _precompute_projection_cache(
823
- graph: ContextGraph,
824
- counts: dict[Context, dict[Symbol, float]],
825
- transition_projection: Callable[[Context, Symbol, Edge], Hashable],
826
- ) -> _ProjectionCache:
827
- projected_counts: dict[Context, dict[Hashable, float]] = {}
828
- dominant_next: dict[Context, dict[Hashable, Context]] = {}
829
- for state in graph.states:
830
- state_projected: Counter[Hashable] = Counter()
831
- destination_counts: dict[Hashable, Counter[Context]] = defaultdict(Counter)
832
- state_counts = counts.get(state, {})
833
- for edge in graph.outgoing(state):
834
- count = state_counts.get(edge.symbol, 0.0)
835
- if count <= 0.0:
836
- continue
837
- projected_symbol = transition_projection(state, edge.symbol, edge)
838
- state_projected[projected_symbol] += count
839
- destination_counts[projected_symbol][edge.next_state] += count
840
- projected_counts[state] = dict(state_projected)
841
- dominant_next[state] = {
842
- projected_symbol: max(
843
- destinations,
844
- key=lambda candidate: (destinations[candidate], repr(candidate)),
845
- )
846
- for projected_symbol, destinations in destination_counts.items()
847
- }
848
- return _ProjectionCache(counts=projected_counts, dominant_next=dominant_next)
849
-
850
-
851
- def _cache_pair_result(
852
- pair_memo: _PairMemo,
853
- pair: tuple[Context, Context],
854
- compatible: bool,
855
- pairs: list[tuple[Context, Context]],
856
- cache_positive: bool,
857
- ) -> tuple[bool, list[tuple[Context, Context]]]:
858
- frozen_pairs = tuple(pairs)
859
- if not compatible or cache_positive:
860
- pair_memo[pair] = (compatible, frozen_pairs)
861
- return compatible, list(frozen_pairs)
862
-
863
-
864
- def _hoeffding_bound(left_support: float, right_support: float, alpha: float) -> float:
865
- scale = math.log(2.0 / alpha) / 2.0
866
- return math.sqrt(scale / left_support) + math.sqrt(scale / right_support)
867
-
868
-
869
- class _ClassRegistry:
870
- def __init__(self, states: tuple[Context, ...]) -> None:
871
- self.union_find = _UnionFind(states)
872
- self._state_index = {state: index for index, state in enumerate(states)}
873
- self._active_roots = list(states)
874
- self._members_by_root = {state: [state] for state in states}
875
-
876
- def find(self, state: Context) -> Context:
877
- return self.union_find.find(state)
878
-
879
- def roots(self) -> tuple[Context, ...]:
880
- return tuple(self._active_roots)
881
-
882
- def members(self, root: Context) -> list[Context]:
883
- return self._members_by_root[self.find(root)]
884
-
885
- def union(self, left: Context, right: Context) -> Context:
886
- left_root = self.union_find.find(left)
887
- right_root = self.union_find.find(right)
888
- if left_root == right_root:
889
- return left_root
890
-
891
- root = self.union_find.union(left_root, right_root)
892
- removed_root = right_root if root == left_root else left_root
893
- self._members_by_root[root] = self._merge_members(
894
- self._members_by_root[root],
895
- self._members_by_root.pop(removed_root),
896
- )
897
- self._active_roots.remove(removed_root)
898
- return root
899
-
900
- def _merge_members(self, left: list[Context], right: list[Context]) -> list[Context]:
901
- merged: list[Context] = []
902
- left_index = 0
903
- right_index = 0
904
- while left_index < len(left) and right_index < len(right):
905
- if self._state_index[left[left_index]] <= self._state_index[right[right_index]]:
906
- merged.append(left[left_index])
907
- left_index += 1
908
- else:
909
- merged.append(right[right_index])
910
- right_index += 1
911
- merged.extend(left[left_index:])
912
- merged.extend(right[right_index:])
913
- return merged
914
-
915
-
916
- def _context_sort_key(context: Context) -> tuple[int, str]:
917
- return (len(context), repr(context))
918
-
919
-
920
- def _hashable_key(value: Any) -> Hashable:
921
- try:
922
- hash(value)
923
- except TypeError:
924
- return (type(value).__name__, repr(value))
925
- return (type(value).__name__, value)
926
-
927
-
928
- def _identity_projection(_state: Context, symbol: Symbol, _edge: Edge) -> Hashable:
929
- return symbol
930
-
931
-
932
- def _transition_projector(
933
- *,
934
- symbol_projection: Callable[[Symbol], Hashable] | None,
935
- transition_projection: Callable[[Context, Symbol, Edge], Hashable] | None,
936
- ) -> Callable[[Context, Symbol, Edge], Hashable]:
937
- if transition_projection is not None:
938
- return transition_projection
939
- if symbol_projection is not None:
940
- return lambda _state, symbol, _edge: symbol_projection(symbol)
941
- return _identity_projection
942
-
943
-
944
- def _projection_name(projection: Callable[..., Hashable] | None) -> str | None:
945
- if projection is None:
946
- return None
947
- return getattr(projection, "__name__", repr(projection))
948
-
949
-
950
- def _projection_kind(
951
- symbol_projection: Callable[[Symbol], Hashable] | None,
952
- transition_projection: Callable[[Context, Symbol, Edge], Hashable] | None,
953
- ) -> str:
954
- if transition_projection is not None:
955
- return "transition"
956
- if symbol_projection is not None:
957
- return "symbol"
958
- return "identity"
959
-
960
-
961
- def _ratio(numerator: int, denominator: int) -> float:
962
- if denominator == 0:
963
- return float("inf") if numerator else 1.0
964
- return float(numerator) / float(denominator)
965
-
966
-
967
- class _UnionFind:
968
- def __init__(self, states: tuple[Context, ...]) -> None:
969
- self.parent = {state: state for state in states}
970
-
971
- def find(self, state: Context) -> Context:
972
- parent = self.parent[state]
973
- if parent != state:
974
- parent = self.find(parent)
975
- self.parent[state] = parent
976
- return parent
977
-
978
- def union(self, left: Context, right: Context) -> Context:
979
- left_root = self.find(left)
980
- right_root = self.find(right)
981
- if left_root == right_root:
982
- return left_root
983
- if _context_sort_key(right_root) < _context_sort_key(left_root):
984
- left_root, right_root = right_root, left_root
985
- self.parent[right_root] = left_root
986
- return left_root
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/metrics.py DELETED
@@ -1,24 +0,0 @@
1
- """Small metrics for exactness experiments."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections import Counter
6
- from typing import Hashable, Iterable, Mapping
7
-
8
- Symbol = Hashable
9
-
10
-
11
- def total_variation(
12
- p: Mapping[tuple[Symbol, ...], float],
13
- q: Mapping[tuple[Symbol, ...], float],
14
- ) -> float:
15
- support = set(p) | set(q)
16
- return 0.5 * sum(abs(float(p.get(item, 0.0)) - float(q.get(item, 0.0))) for item in support)
17
-
18
-
19
- def empirical_distribution(samples: Iterable[tuple[Symbol, ...]]) -> dict[tuple[Symbol, ...], float]:
20
- counts = Counter(samples)
21
- total = sum(counts.values())
22
- if total == 0:
23
- return {}
24
- return {sample: count / total for sample, count in counts.items()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/minimization.py DELETED
@@ -1,326 +0,0 @@
1
- """Exact quotient/minimization helpers for stochastic context graphs."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Hashable, Iterable, Sequence
6
- from dataclasses import dataclass
7
- from typing import Any
8
-
9
- from .context import Context, ContextGraph, Edge
10
-
11
-
12
- @dataclass(frozen=True)
13
- class ExactQuotientStats:
14
- """State/edge counts for an exact graph quotient."""
15
-
16
- states: int
17
- classes: int
18
- edges: int
19
- quotient_edges: int
20
- max_class_size: int
21
- refinement_rounds: int
22
-
23
- @property
24
- def state_reduction(self) -> float:
25
- return _ratio(self.states, self.classes)
26
-
27
- @property
28
- def edge_reduction(self) -> float:
29
- return _ratio(self.edges, self.quotient_edges)
30
-
31
- def as_dict(self) -> dict[str, int | float]:
32
- return {
33
- "states": self.states,
34
- "classes": self.classes,
35
- "edges": self.edges,
36
- "quotient_edges": self.quotient_edges,
37
- "max_class_size": self.max_class_size,
38
- "refinement_rounds": self.refinement_rounds,
39
- "state_reduction": self.state_reduction,
40
- "edge_reduction": self.edge_reduction,
41
- }
42
-
43
-
44
- def exact_context_graph_quotient_stats(graph: ContextGraph) -> ExactQuotientStats:
45
- """Return exact quotient counts for a :class:`ContextGraph`."""
46
-
47
- partition = _context_graph_partition(graph)
48
- return partition.stats
49
-
50
-
51
- def minimize_context_graph(graph: ContextGraph) -> ContextGraph:
52
- """Return a read-only exact quotient of ``graph``.
53
-
54
- The quotient keeps one representative context per equivalence class and
55
- records aliases so callers may still ask for outgoing edges using original
56
- context labels.
57
- """
58
-
59
- partition = _context_graph_partition(graph)
60
- if partition.stats.classes == partition.stats.states:
61
- return graph
62
-
63
- representative_by_class = partition.representatives
64
- representative_for_state = {
65
- state: representative_by_class[partition.class_by_state[state]]
66
- for state in partition.states
67
- }
68
- edges_by_state: dict[Context, list[Edge]] = {}
69
- for class_id, representative in enumerate(representative_by_class):
70
- edges_by_state[representative] = [
71
- Edge(
72
- edge.symbol,
73
- edge.probability,
74
- representative_for_state[edge.next_state],
75
- edge.order_weights,
76
- )
77
- for edge in graph.outgoing(representative)
78
- ]
79
-
80
- result = ContextGraph(
81
- edges_by_state,
82
- start_state=representative_for_state[graph.start_state],
83
- max_order=graph.max_order,
84
- alphabet=graph.alphabet,
85
- validate=True,
86
- )
87
- result._alias_to_state = representative_for_state # type: ignore[attr-defined]
88
- result._quotient_stats = partition.stats # type: ignore[attr-defined]
89
- result._quotient_classes = tuple( # type: ignore[attr-defined]
90
- tuple(
91
- state
92
- for state in partition.states
93
- if partition.class_by_state[state] == class_id
94
- )
95
- for class_id in range(len(representative_by_class))
96
- )
97
- return result
98
-
99
-
100
- def exact_fixed_order_graph_quotient_stats(graph: object) -> ExactQuotientStats:
101
- """Return exact quotient counts for a fixed-order context graph."""
102
-
103
- partition = _fixed_order_partition(graph)
104
- return partition.stats
105
-
106
-
107
- def minimize_fixed_order_graph(graph: object) -> object:
108
- """Return a minimized fixed-order graph, preserving original context lookup."""
109
-
110
- partition = _fixed_order_partition(graph)
111
- if partition.stats.classes == partition.stats.states:
112
- return graph
113
-
114
- from .order_stack_bp import FixedOrderContextGraph, StackEdge
115
-
116
- minimized = FixedOrderContextGraph(graph.order)
117
- representative_by_class = partition.representatives
118
- minimized.contexts = [graph.contexts[index] for index in representative_by_class]
119
- minimized.outgoing = [[] for _ in representative_by_class]
120
-
121
- for original_context, original_state in graph.context_to_id.items():
122
- minimized.context_to_id[original_context] = partition.class_by_state[original_state]
123
-
124
- aliases: list[list[Context]] = [[] for _ in representative_by_class]
125
- for state, context in enumerate(graph.contexts):
126
- aliases[partition.class_by_state[state]].append(context)
127
- minimized.state_aliases = tuple(tuple(group) for group in aliases)
128
- minimized.quotient_stats = partition.stats
129
-
130
- for class_id, representative_state in enumerate(representative_by_class):
131
- minimized.outgoing[class_id] = [
132
- StackEdge(
133
- src=class_id,
134
- dst=partition.class_by_state[edge.dst],
135
- symbol=edge.symbol,
136
- probability=edge.probability,
137
- order=edge.order,
138
- )
139
- for edge in graph.outgoing[representative_state]
140
- ]
141
-
142
- return minimized
143
-
144
-
145
- def minimize_fixed_order_graphs(graphs: dict[int, object]) -> dict[int, object]:
146
- """Minimize every materialized fixed-order graph in ``graphs``."""
147
-
148
- return {
149
- order: minimize_fixed_order_graph(graph)
150
- for order, graph in graphs.items()
151
- }
152
-
153
-
154
- @dataclass(frozen=True)
155
- class _ContextGraphPartition:
156
- states: tuple[Context, ...]
157
- class_by_state: dict[Context, int]
158
- representatives: tuple[Context, ...]
159
- stats: ExactQuotientStats
160
-
161
-
162
- @dataclass(frozen=True)
163
- class _FixedOrderPartition:
164
- class_by_state: tuple[int, ...]
165
- representatives: tuple[int, ...]
166
- stats: ExactQuotientStats
167
-
168
-
169
- def _context_graph_partition(graph: ContextGraph) -> _ContextGraphPartition:
170
- states = tuple(sorted(graph.states, key=_context_sort_key))
171
- state_index = {state: index for index, state in enumerate(states)}
172
- classes = [0 for _state in states]
173
- rounds = 0
174
-
175
- while True:
176
- signatures = [
177
- tuple(
178
- sorted(
179
- (
180
- _hashable_key(edge.symbol),
181
- _float_key(edge.probability),
182
- _order_weights_key(edge.order_weights),
183
- classes[state_index[edge.next_state]],
184
- )
185
- for edge in graph.outgoing(state)
186
- )
187
- )
188
- for state in states
189
- ]
190
- new_classes, counts = _classes_from_signatures(signatures)
191
- rounds += 1
192
- if new_classes == classes:
193
- representatives = _representative_states(new_classes)
194
- quotient_edges = sum(
195
- len(graph.outgoing(states[state]))
196
- for state in representatives
197
- )
198
- return _ContextGraphPartition(
199
- states=states,
200
- class_by_state={
201
- state: class_id
202
- for state, class_id in zip(states, new_classes)
203
- },
204
- representatives=tuple(states[state] for state in representatives),
205
- stats=ExactQuotientStats(
206
- states=len(states),
207
- classes=len(counts),
208
- edges=graph.edge_count(),
209
- quotient_edges=quotient_edges,
210
- max_class_size=max(counts or [0]),
211
- refinement_rounds=rounds,
212
- ),
213
- )
214
- classes = new_classes
215
-
216
-
217
- def _fixed_order_partition(graph: object) -> _FixedOrderPartition:
218
- _require_materialized_fixed_order_graph(graph)
219
- state_count = len(graph.contexts)
220
- classes = [0 for _state in graph.contexts]
221
- rounds = 0
222
-
223
- while True:
224
- signatures = [
225
- tuple(
226
- sorted(
227
- (
228
- _hashable_key(edge.symbol),
229
- _float_key(edge.probability),
230
- int(edge.order),
231
- classes[edge.dst],
232
- )
233
- for edge in edges
234
- )
235
- )
236
- for edges in graph.outgoing
237
- ]
238
- new_classes, counts = _classes_from_signatures(signatures)
239
- rounds += 1
240
- if new_classes == classes:
241
- representatives = _representative_states(new_classes)
242
- quotient_edges = sum(
243
- len(graph.outgoing[state])
244
- for state in representatives
245
- )
246
- return _FixedOrderPartition(
247
- class_by_state=tuple(new_classes),
248
- representatives=tuple(representatives),
249
- stats=ExactQuotientStats(
250
- states=state_count,
251
- classes=len(counts),
252
- edges=graph.edge_count,
253
- quotient_edges=quotient_edges,
254
- max_class_size=max(counts or [0]),
255
- refinement_rounds=rounds,
256
- ),
257
- )
258
- classes = new_classes
259
-
260
-
261
- def _classes_from_signatures(
262
- signatures: Sequence[Hashable],
263
- ) -> tuple[list[int], list[int]]:
264
- class_ids: dict[Hashable, int] = {}
265
- classes: list[int] = []
266
- counts: list[int] = []
267
- for signature in signatures:
268
- class_id = class_ids.get(signature)
269
- if class_id is None:
270
- class_id = len(class_ids)
271
- class_ids[signature] = class_id
272
- counts.append(0)
273
- classes.append(class_id)
274
- counts[class_id] += 1
275
- return classes, counts
276
-
277
-
278
- def _representative_states(classes: Sequence[int]) -> list[int]:
279
- representatives: dict[int, int] = {}
280
- for state, class_id in enumerate(classes):
281
- representatives.setdefault(class_id, state)
282
- return [representatives[class_id] for class_id in range(len(representatives))]
283
-
284
-
285
- def _require_materialized_fixed_order_graph(graph: object) -> None:
286
- from .order_stack_bp import FixedOrderContextGraph
287
-
288
- if not isinstance(graph, FixedOrderContextGraph):
289
- raise TypeError(
290
- "exact source-graph minimization currently supports materialized "
291
- "FixedOrderContextGraph instances"
292
- )
293
- required = ("order", "contexts", "context_to_id", "outgoing", "edge_count")
294
- if not all(hasattr(graph, name) for name in required):
295
- raise TypeError("expected a materialized fixed-order context graph")
296
- if len(graph.outgoing) != len(graph.contexts):
297
- raise ValueError(
298
- "exact minimization requires materialized graph outgoing rows; "
299
- "lazy graph views are not supported"
300
- )
301
-
302
-
303
- def _context_sort_key(context: Context) -> tuple[int, str]:
304
- return (len(context), repr(context))
305
-
306
-
307
- def _hashable_key(value: Any) -> Hashable:
308
- try:
309
- hash(value)
310
- except TypeError:
311
- return (type(value).__name__, repr(value))
312
- return (type(value).__name__, value)
313
-
314
-
315
- def _float_key(value: float) -> tuple[int, int]:
316
- return float(value).as_integer_ratio()
317
-
318
-
319
- def _order_weights_key(weights: Iterable[tuple[int, float]]) -> tuple[tuple[int, tuple[int, int]], ...]:
320
- return tuple((int(order), _float_key(weight)) for order, weight in weights)
321
-
322
-
323
- def _ratio(numerator: int, denominator: int) -> float:
324
- if denominator == 0:
325
- return float("inf") if numerator else 1.0
326
- return float(numerator) / float(denominator)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/orbit_diagnostics.py DELETED
@@ -1,574 +0,0 @@
1
- """Diagnostics for transformation-orbit structure in regular BP products."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections import Counter
6
- from collections.abc import Hashable, Iterable, Mapping
7
- from dataclasses import dataclass
8
- from numbers import Integral
9
- from typing import Any
10
-
11
- from .context import Symbol
12
- from .order_stack_bp import RegularOrderStackBPResult
13
- from .positional_bp import PositionConstraint
14
-
15
-
16
- @dataclass(frozen=True)
17
- class ForbiddenPatternOrbitStats:
18
- """Orbit counts for forbidden patterns and their DFA prefix states."""
19
-
20
- pattern_count: int
21
- pattern_orbit_count: int
22
- prefix_state_count: int
23
- prefix_state_orbit_count: int
24
- alphabet_size: int | None = None
25
- alphabet_orbit_count: int | None = None
26
-
27
- @property
28
- def pattern_reduction_factor(self) -> float:
29
- return _ratio(self.pattern_count, self.pattern_orbit_count)
30
-
31
- @property
32
- def prefix_state_reduction_factor(self) -> float:
33
- return _ratio(self.prefix_state_count, self.prefix_state_orbit_count)
34
-
35
- @property
36
- def alphabet_reduction_factor(self) -> float | None:
37
- if self.alphabet_size is None or self.alphabet_orbit_count is None:
38
- return None
39
- return _ratio(self.alphabet_size, self.alphabet_orbit_count)
40
-
41
- def as_dict(self) -> dict[str, int | float | None]:
42
- return {
43
- "forbidden_patterns": self.pattern_count,
44
- "forbidden_pattern_orbits": self.pattern_orbit_count,
45
- "forbidden_pattern_reduction": self.pattern_reduction_factor,
46
- "dfa_prefix_states": self.prefix_state_count,
47
- "dfa_prefix_state_orbits": self.prefix_state_orbit_count,
48
- "dfa_prefix_state_reduction": self.prefix_state_reduction_factor,
49
- "alphabet_size": self.alphabet_size,
50
- "alphabet_orbits": self.alphabet_orbit_count,
51
- "alphabet_reduction": self.alphabet_reduction_factor,
52
- }
53
-
54
-
55
- @dataclass(frozen=True)
56
- class RegularProductOrbitStats:
57
- """Orbit counts for the currently expanded regular BP product."""
58
-
59
- time_indexed_product_states: int
60
- time_indexed_product_state_orbits: int
61
- product_states: int
62
- product_state_orbits: int
63
- product_edges: int
64
- time_indexed_product_edge_orbits: int
65
- product_edge_orbits: int
66
- expanded_rows: int
67
- transition_row_shape_orbits: int
68
- max_time_state_orbit_size: int
69
- max_product_state_orbit_size: int
70
- max_time_edge_orbit_size: int
71
- max_edge_orbit_size: int
72
-
73
- @property
74
- def time_indexed_product_state_reduction_factor(self) -> float:
75
- return _ratio(
76
- self.time_indexed_product_states,
77
- self.time_indexed_product_state_orbits,
78
- )
79
-
80
- @property
81
- def product_state_reduction_factor(self) -> float:
82
- return _ratio(self.product_states, self.product_state_orbits)
83
-
84
- @property
85
- def time_indexed_product_edge_reduction_factor(self) -> float:
86
- return _ratio(self.product_edges, self.time_indexed_product_edge_orbits)
87
-
88
- @property
89
- def product_edge_reduction_factor(self) -> float:
90
- return _ratio(self.product_edges, self.product_edge_orbits)
91
-
92
- @property
93
- def transition_row_shape_reduction_factor(self) -> float:
94
- return _ratio(self.expanded_rows, self.transition_row_shape_orbits)
95
-
96
- def as_dict(self) -> dict[str, int | float]:
97
- return {
98
- "time_indexed_product_states": self.time_indexed_product_states,
99
- "time_indexed_product_state_orbits": self.time_indexed_product_state_orbits,
100
- "time_indexed_product_state_reduction": (
101
- self.time_indexed_product_state_reduction_factor
102
- ),
103
- "product_states": self.product_states,
104
- "product_state_orbits": self.product_state_orbits,
105
- "product_state_reduction": self.product_state_reduction_factor,
106
- "product_edges": self.product_edges,
107
- "time_indexed_product_edge_orbits": self.time_indexed_product_edge_orbits,
108
- "time_indexed_product_edge_reduction": (
109
- self.time_indexed_product_edge_reduction_factor
110
- ),
111
- "product_edge_orbits": self.product_edge_orbits,
112
- "product_edge_reduction": self.product_edge_reduction_factor,
113
- "expanded_rows": self.expanded_rows,
114
- "transition_row_shape_orbits": self.transition_row_shape_orbits,
115
- "transition_row_shape_reduction": self.transition_row_shape_reduction_factor,
116
- "max_time_state_orbit_size": self.max_time_state_orbit_size,
117
- "max_product_state_orbit_size": self.max_product_state_orbit_size,
118
- "max_time_edge_orbit_size": self.max_time_edge_orbit_size,
119
- "max_edge_orbit_size": self.max_edge_orbit_size,
120
- }
121
-
122
-
123
- @dataclass(frozen=True)
124
- class RegularRowSignatureStats:
125
- """Exact shifted-row reuse counts for already-expanded regular BP rows."""
126
-
127
- product_rows: int
128
- product_row_signatures: int
129
- reusable_product_rows: int
130
- max_product_row_signature_size: int
131
- time_masked_rows: int
132
- time_masked_row_signatures: int
133
- reusable_time_masked_rows: int
134
- max_time_masked_row_signature_size: int
135
- time_masked_edges: int
136
-
137
- @property
138
- def product_row_reduction_factor(self) -> float:
139
- return _ratio(self.product_rows, self.product_row_signatures)
140
-
141
- @property
142
- def reusable_product_row_fraction(self) -> float:
143
- return _ratio(self.reusable_product_rows, self.product_rows)
144
-
145
- @property
146
- def time_masked_row_reduction_factor(self) -> float:
147
- return _ratio(self.time_masked_rows, self.time_masked_row_signatures)
148
-
149
- @property
150
- def reusable_time_masked_row_fraction(self) -> float:
151
- return _ratio(self.reusable_time_masked_rows, self.time_masked_rows)
152
-
153
- def as_dict(self) -> dict[str, int | float]:
154
- return {
155
- "exact_product_rows": self.product_rows,
156
- "exact_product_row_signatures": self.product_row_signatures,
157
- "exact_product_row_reduction": self.product_row_reduction_factor,
158
- "exact_reusable_product_rows": self.reusable_product_rows,
159
- "exact_reusable_product_row_fraction": self.reusable_product_row_fraction,
160
- "exact_max_product_row_signature_size": self.max_product_row_signature_size,
161
- "exact_time_masked_rows": self.time_masked_rows,
162
- "exact_time_masked_row_signatures": self.time_masked_row_signatures,
163
- "exact_time_masked_row_reduction": self.time_masked_row_reduction_factor,
164
- "exact_reusable_time_masked_rows": self.reusable_time_masked_rows,
165
- "exact_reusable_time_masked_row_fraction": (
166
- self.reusable_time_masked_row_fraction
167
- ),
168
- "exact_max_time_masked_row_signature_size": (
169
- self.max_time_masked_row_signature_size
170
- ),
171
- "exact_time_masked_edges": self.time_masked_edges,
172
- }
173
-
174
-
175
- def canonical_integer_shift_key(
176
- value: Any,
177
- *,
178
- fixed_symbols: Iterable[Symbol] = (),
179
- reference: int | None = None,
180
- ) -> Hashable:
181
- """Canonicalize nested symbols modulo a common integer shift.
182
-
183
- Integer-like symbols are represented relative to the first integer symbol
184
- unless ``reference`` is supplied. Non-integer symbols, such as sentinels,
185
- are kept fixed. The function is intentionally diagnostic: it exposes how
186
- much repeated structure is present under transposition without changing any
187
- BP semantics.
188
- """
189
-
190
- fixed = frozenset(fixed_symbols)
191
- origin = reference
192
- if origin is None:
193
- first = _first_shiftable_symbol(value, fixed)
194
- origin = 0 if first is None else first
195
- return _canonicalize(value, fixed, int(origin))
196
-
197
-
198
- def forbidden_pattern_orbit_stats(
199
- patterns: Iterable[Iterable[Symbol]],
200
- *,
201
- alphabet: Iterable[Symbol] | None = None,
202
- fixed_symbols: Iterable[Symbol] = (),
203
- ) -> ForbiddenPatternOrbitStats:
204
- """Measure integer-shift orbit structure in forbidden n-grams."""
205
-
206
- fixed = frozenset(fixed_symbols)
207
- pattern_tuple = tuple(dict.fromkeys(tuple(pattern) for pattern in patterns))
208
- prefixes: set[tuple[Symbol, ...]] = {()}
209
- for pattern in pattern_tuple:
210
- for prefix_len in range(1, len(pattern)):
211
- prefixes.add(pattern[:prefix_len])
212
-
213
- pattern_orbits = {
214
- canonical_integer_shift_key(pattern, fixed_symbols=fixed)
215
- for pattern in pattern_tuple
216
- }
217
- prefix_orbits = {
218
- canonical_integer_shift_key(prefix, fixed_symbols=fixed)
219
- for prefix in prefixes
220
- }
221
-
222
- alphabet_size = None
223
- alphabet_orbit_count = None
224
- if alphabet is not None:
225
- alphabet_tuple = tuple(dict.fromkeys(alphabet))
226
- alphabet_size = len(alphabet_tuple)
227
- alphabet_orbit_count = len(
228
- {
229
- canonical_integer_shift_key(symbol, fixed_symbols=fixed)
230
- for symbol in alphabet_tuple
231
- }
232
- )
233
-
234
- return ForbiddenPatternOrbitStats(
235
- pattern_count=len(pattern_tuple),
236
- pattern_orbit_count=len(pattern_orbits),
237
- prefix_state_count=len(prefixes),
238
- prefix_state_orbit_count=len(prefix_orbits),
239
- alphabet_size=alphabet_size,
240
- alphabet_orbit_count=alphabet_orbit_count,
241
- )
242
-
243
-
244
- def regular_product_orbit_stats(
245
- result: RegularOrderStackBPResult,
246
- *,
247
- fixed_symbols: Iterable[Symbol] = (),
248
- include_transition_row_shapes: bool = False,
249
- ) -> RegularProductOrbitStats:
250
- """Measure transposition-orbit repetition in an already-run regular BP."""
251
-
252
- fixed = frozenset(fixed_symbols)
253
- result.start_order_masses()
254
-
255
- time_state_counts: Counter[Hashable] = Counter()
256
- state_counts: Counter[Hashable] = Counter()
257
- time_edge_counts: Counter[Hashable] = Counter()
258
- edge_counts: Counter[Hashable] = Counter()
259
- row_shapes: set[Hashable] = set()
260
- state_seen: set[Hashable] = set()
261
- state_key_cache: dict[tuple[int, int, Hashable], Hashable] = {}
262
- acceptor_payload_cache: dict[Hashable, Hashable] = {}
263
- edge_key_cache: dict[tuple[int, int, Hashable, int, Symbol, Hashable], Hashable] = {}
264
- scanned_edge_count = 0
265
- expanded_rows = 0
266
-
267
- for order, cache in result.backwards.items():
268
- graph = cache.graph
269
- for time, graph_state, acceptor_state in cache.memo:
270
- context = graph.contexts[graph_state]
271
- acceptor_payload = acceptor_payload_cache.get(acceptor_state)
272
- if acceptor_payload is None:
273
- acceptor_payload = _acceptor_state_payload(result.acceptor, acceptor_state)
274
- acceptor_payload_cache[acceptor_state] = acceptor_payload
275
-
276
- raw_state_key = (order, graph_state, acceptor_state)
277
- state_key = state_key_cache.get(raw_state_key)
278
- if state_key is None:
279
- state_payload = (context, acceptor_payload)
280
- state_key = (
281
- order,
282
- canonical_integer_shift_key(state_payload, fixed_symbols=fixed),
283
- )
284
- state_key_cache[raw_state_key] = state_key
285
- time_state_key = (time, *state_key)
286
- time_state_counts[time_state_key] += 1
287
- if raw_state_key not in state_seen:
288
- state_seen.add(raw_state_key)
289
- state_counts[state_key] += 1
290
-
291
- if time == result.length:
292
- continue
293
-
294
- expanded_rows += 1
295
- row_edges: list[Hashable] = []
296
- reference = None
297
- if include_transition_row_shapes:
298
- reference = _first_shiftable_symbol((context, acceptor_payload), fixed)
299
- for edge, next_acceptor_state, _transition_weight in cache.accepted_transitions(
300
- graph_state,
301
- acceptor_state,
302
- ):
303
- if not _constraint_allows(result.constraints.get(time), edge.symbol):
304
- continue
305
- edge_cache_key = (
306
- order,
307
- graph_state,
308
- acceptor_state,
309
- edge.dst,
310
- edge.symbol,
311
- next_acceptor_state,
312
- )
313
- edge_key = edge_key_cache.get(edge_cache_key)
314
- if edge_key is None:
315
- next_payload = acceptor_payload_cache.get(next_acceptor_state)
316
- if next_payload is None:
317
- next_payload = _acceptor_state_payload(
318
- result.acceptor,
319
- next_acceptor_state,
320
- )
321
- acceptor_payload_cache[next_acceptor_state] = next_payload
322
- dst_context = graph.contexts[edge.dst]
323
- edge_payload = (
324
- context,
325
- acceptor_payload,
326
- edge.symbol,
327
- dst_context,
328
- next_payload,
329
- edge.probability,
330
- )
331
- edge_key = (
332
- order,
333
- canonical_integer_shift_key(edge_payload, fixed_symbols=fixed),
334
- )
335
- edge_key_cache[edge_cache_key] = edge_key
336
- time_edge_key = (time, *edge_key)
337
- edge_counts[edge_key] += 1
338
- time_edge_counts[time_edge_key] += 1
339
- scanned_edge_count += 1
340
-
341
- if include_transition_row_shapes:
342
- next_payload = acceptor_payload_cache[next_acceptor_state]
343
- dst_context = graph.contexts[edge.dst]
344
- row_reference = reference
345
- if row_reference is None:
346
- row_reference = _first_shiftable_symbol(
347
- (edge.symbol, dst_context, next_payload),
348
- fixed,
349
- )
350
- row_edges.append(
351
- canonical_integer_shift_key(
352
- (edge.symbol, dst_context, next_payload, edge.probability),
353
- fixed_symbols=fixed,
354
- reference=row_reference,
355
- )
356
- )
357
-
358
- if include_transition_row_shapes:
359
- row_key = (
360
- order,
361
- canonical_integer_shift_key(
362
- (context, acceptor_payload),
363
- fixed_symbols=fixed,
364
- reference=reference,
365
- ),
366
- tuple(sorted(row_edges, key=repr)),
367
- )
368
- row_shapes.add(row_key)
369
-
370
- if scanned_edge_count != result.product_edge_count:
371
- raise RuntimeError(
372
- "orbit diagnostic edge scan did not match BP product-edge count: "
373
- f"{scanned_edge_count} != {result.product_edge_count}"
374
- )
375
-
376
- return RegularProductOrbitStats(
377
- time_indexed_product_states=result.time_indexed_product_state_count,
378
- time_indexed_product_state_orbits=len(time_state_counts),
379
- product_states=result.product_state_count,
380
- product_state_orbits=len(state_counts),
381
- product_edges=result.product_edge_count,
382
- time_indexed_product_edge_orbits=len(time_edge_counts),
383
- product_edge_orbits=len(edge_counts),
384
- expanded_rows=expanded_rows,
385
- transition_row_shape_orbits=len(row_shapes),
386
- max_time_state_orbit_size=_max_count(time_state_counts),
387
- max_product_state_orbit_size=_max_count(state_counts),
388
- max_time_edge_orbit_size=_max_count(time_edge_counts),
389
- max_edge_orbit_size=_max_count(edge_counts),
390
- )
391
-
392
-
393
- def regular_row_signature_stats(
394
- result: RegularOrderStackBPResult,
395
- *,
396
- fixed_symbols: Iterable[Symbol] = (),
397
- ) -> RegularRowSignatureStats:
398
- """Count exact row-template reuse under integer-shift canonicalization.
399
-
400
- Unlike the looser orbit edge counters, a row signature contains the
401
- canonical source payload, every accepted symbol, successor context, successor
402
- DFA payload, and transition probability. Finite-transform boundary effects
403
- and nonuniform continuation probabilities therefore split into distinct
404
- signatures instead of being merged.
405
- """
406
-
407
- fixed = frozenset(fixed_symbols)
408
- result.start_order_masses()
409
-
410
- acceptor_payload_cache: dict[Hashable, Hashable] = {}
411
- product_row_counts: Counter[Hashable] = Counter()
412
- time_masked_row_counts: Counter[Hashable] = Counter()
413
- product_seen: set[tuple[int, int, Hashable]] = set()
414
- time_masked_edges = 0
415
-
416
- for order, cache in result.backwards.items():
417
- graph = cache.graph
418
- for time, graph_state, acceptor_state in cache.memo:
419
- if time == result.length:
420
- continue
421
-
422
- product_key = (order, graph_state, acceptor_state)
423
- if product_key not in product_seen:
424
- product_seen.add(product_key)
425
- signature, _edge_count = _regular_row_signature(
426
- result,
427
- order=order,
428
- graph_state=graph_state,
429
- acceptor_state=acceptor_state,
430
- constraint=None,
431
- acceptor_payload_cache=acceptor_payload_cache,
432
- fixed_symbols=fixed,
433
- )
434
- product_row_counts[signature] += 1
435
-
436
- time_signature, edge_count = _regular_row_signature(
437
- result,
438
- order=order,
439
- graph_state=graph_state,
440
- acceptor_state=acceptor_state,
441
- constraint=result.constraints.get(time),
442
- acceptor_payload_cache=acceptor_payload_cache,
443
- fixed_symbols=fixed,
444
- )
445
- time_masked_row_counts[time_signature] += 1
446
- time_masked_edges += edge_count
447
-
448
- if time_masked_edges != result.product_edge_count:
449
- raise RuntimeError(
450
- "row-signature edge scan did not match BP product-edge count: "
451
- f"{time_masked_edges} != {result.product_edge_count}"
452
- )
453
-
454
- return RegularRowSignatureStats(
455
- product_rows=len(product_seen),
456
- product_row_signatures=len(product_row_counts),
457
- reusable_product_rows=_reusable_count(product_row_counts),
458
- max_product_row_signature_size=_max_count(product_row_counts),
459
- time_masked_rows=sum(time_masked_row_counts.values()),
460
- time_masked_row_signatures=len(time_masked_row_counts),
461
- reusable_time_masked_rows=_reusable_count(time_masked_row_counts),
462
- max_time_masked_row_signature_size=_max_count(time_masked_row_counts),
463
- time_masked_edges=time_masked_edges,
464
- )
465
-
466
-
467
- def _acceptor_state_payload(acceptor: Any, state: Hashable) -> Hashable:
468
- prefixes = getattr(acceptor, "prefixes", None)
469
- if prefixes is not None and isinstance(state, int) and 0 <= state < len(prefixes):
470
- return tuple(prefixes[state])
471
- return ("dfa_state", state)
472
-
473
-
474
- def _constraint_allows(constraint: PositionConstraint | None, symbol: Symbol) -> bool:
475
- if constraint is None:
476
- return True
477
- if callable(constraint):
478
- return bool(constraint(symbol))
479
- return symbol in constraint
480
-
481
-
482
- def _regular_row_signature(
483
- result: RegularOrderStackBPResult,
484
- *,
485
- order: int,
486
- graph_state: int,
487
- acceptor_state: Hashable,
488
- constraint: PositionConstraint | None,
489
- acceptor_payload_cache: dict[Hashable, Hashable],
490
- fixed_symbols: frozenset[Symbol],
491
- ) -> tuple[Hashable, int]:
492
- cache = result.backwards[order]
493
- graph = cache.graph
494
- context = graph.contexts[graph_state]
495
- acceptor_payload = acceptor_payload_cache.get(acceptor_state)
496
- if acceptor_payload is None:
497
- acceptor_payload = _acceptor_state_payload(result.acceptor, acceptor_state)
498
- acceptor_payload_cache[acceptor_state] = acceptor_payload
499
-
500
- source_payload = (context, acceptor_payload)
501
- reference = _first_shiftable_symbol(source_payload, fixed_symbols)
502
- source_key = canonical_integer_shift_key(
503
- source_payload,
504
- fixed_symbols=fixed_symbols,
505
- reference=reference,
506
- )
507
-
508
- transition_keys: list[Hashable] = []
509
- edge_count = 0
510
- for edge, next_acceptor_state, _transition_weight in cache.accepted_transitions(
511
- graph_state,
512
- acceptor_state,
513
- ):
514
- if not _constraint_allows(constraint, edge.symbol):
515
- continue
516
- next_payload = acceptor_payload_cache.get(next_acceptor_state)
517
- if next_payload is None:
518
- next_payload = _acceptor_state_payload(result.acceptor, next_acceptor_state)
519
- acceptor_payload_cache[next_acceptor_state] = next_payload
520
- dst_context = graph.contexts[edge.dst]
521
- transition_keys.append(
522
- canonical_integer_shift_key(
523
- (
524
- edge.symbol,
525
- dst_context,
526
- next_payload,
527
- edge.probability,
528
- ),
529
- fixed_symbols=fixed_symbols,
530
- reference=reference,
531
- )
532
- )
533
- edge_count += 1
534
-
535
- return (order, source_key, tuple(sorted(transition_keys, key=repr))), edge_count
536
-
537
-
538
- def _canonicalize(value: Any, fixed_symbols: frozenset[Symbol], reference: int) -> Hashable:
539
- if _is_shiftable_integer(value, fixed_symbols):
540
- return ("rel_int", int(value) - reference)
541
- if isinstance(value, tuple):
542
- return tuple(_canonicalize(item, fixed_symbols, reference) for item in value)
543
- if isinstance(value, list):
544
- return tuple(_canonicalize(item, fixed_symbols, reference) for item in value)
545
- return ("fixed", value)
546
-
547
-
548
- def _first_shiftable_symbol(value: Any, fixed_symbols: frozenset[Symbol]) -> int | None:
549
- if _is_shiftable_integer(value, fixed_symbols):
550
- return int(value)
551
- if isinstance(value, (tuple, list)):
552
- for item in value:
553
- found = _first_shiftable_symbol(item, fixed_symbols)
554
- if found is not None:
555
- return found
556
- return None
557
-
558
-
559
- def _is_shiftable_integer(value: Any, fixed_symbols: frozenset[Symbol]) -> bool:
560
- return isinstance(value, Integral) and not isinstance(value, bool) and value not in fixed_symbols
561
-
562
-
563
- def _max_count(counter: Mapping[Hashable, int]) -> int:
564
- return max(counter.values(), default=0)
565
-
566
-
567
- def _reusable_count(counter: Mapping[Hashable, int]) -> int:
568
- return sum(count for count in counter.values() if count > 1)
569
-
570
-
571
- def _ratio(numerator: int, denominator: int) -> float:
572
- if denominator == 0:
573
- return 0.0
574
- return float(numerator) / float(denominator)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/order_stack_bp.py DELETED
The diff for this file is too large to render. See raw diff
 
vendor/vo_regular_bp/vo_regular_bp/padding.py DELETED
@@ -1,35 +0,0 @@
1
- """Utilities for fixed-horizon padded generation."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Iterable, Sequence
6
- from typing import TypeVar
7
-
8
-
9
- SymbolT = TypeVar("SymbolT")
10
-
11
-
12
- def append_padding(
13
- sequences: Iterable[Sequence[SymbolT]],
14
- *,
15
- pad_symbol: SymbolT,
16
- pad_count: int,
17
- ) -> tuple[tuple[SymbolT, ...], ...]:
18
- """Append repeated PAD symbols to each training sequence.
19
-
20
- This is the standard fixed-horizon encoding of variable-length generation:
21
- train on phrase/bar sequences followed by enough PAD symbols for the chosen
22
- model order, then constrain PAD to be zero-cost and absorbing.
23
-
24
- Probabilities are those of the padded model: the first PAD transition is a
25
- modeled stop/ending event, while trailing PAD self-loops should become
26
- deterministic when enough padding is appended.
27
-
28
- Use at least ``max_order + 1`` PAD symbols when building an order-stack
29
- model so every fixed-order PAD context has a PAD self-loop.
30
- """
31
-
32
- if pad_count < 1:
33
- raise ValueError("pad_count must be at least 1")
34
- padding = (pad_symbol,) * int(pad_count)
35
- return tuple(tuple(sequence) + padding for sequence in sequences)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/positional_bp.py DELETED
@@ -1,393 +0,0 @@
1
- """Exact BP for fixed-horizon positional constraints.
2
-
3
- This module is the no-DFA specialization of product BP. Positional constraints
4
- are represented as time-indexed symbol masks, so the recursion only ranges over
5
- context states:
6
-
7
- beta[t, s] = sum_y P(y | s) beta[t + 1, canon(s, y))
8
-
9
- No approximation or pruning is introduced.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- from collections import Counter, defaultdict
15
- from collections.abc import Callable, Iterable, Mapping, Sequence
16
- from dataclasses import dataclass, field
17
- import random
18
- from typing import Protocol
19
-
20
- from .context import Context, ContextGraph, Edge, Symbol, _as_context
21
- from .product_bp import _sample_order
22
-
23
- PositionConstraint = Callable[[Symbol], bool] | Iterable[Symbol]
24
- PositionConstraints = Mapping[int, PositionConstraint]
25
- AllowedForbiddenSymbols = Mapping[int, Iterable[Symbol]]
26
-
27
-
28
- class ContextModel(Protocol):
29
- start_state: Context
30
-
31
- def outgoing(self, context: Iterable[Symbol] | Context) -> tuple[Edge, ...]:
32
- ...
33
-
34
-
35
- class LazyBackoffContextModel:
36
- """Backoff continuation model with outgoing edges compiled on demand.
37
-
38
- The stochastic semantics match ``ContextGraph.from_backoff_sequences``:
39
- each context uses a normalized weighted mixture of its own continuation
40
- distribution and lower-order suffix distributions. The difference is only
41
- operational: edges are materialized when reached by BP.
42
- """
43
-
44
- def __init__(
45
- self,
46
- counts: Mapping[Context, Counter[Symbol]],
47
- *,
48
- max_order: int,
49
- backoff_weight: float,
50
- start_state: Iterable[Symbol] | Context = (),
51
- track_order_weights: bool = False,
52
- ) -> None:
53
- if max_order < 0:
54
- raise ValueError("max_order must be non-negative")
55
- if not 0.0 <= backoff_weight <= 1.0:
56
- raise ValueError("backoff_weight must be in [0, 1]")
57
-
58
- self.counts = dict(counts)
59
- self.max_order = int(max_order)
60
- self.backoff_weight = float(backoff_weight)
61
- self.start_state = _as_context(start_state)
62
- self.track_order_weights = bool(track_order_weights)
63
- contexts = set(self.counts)
64
- contexts.add(self.start_state)
65
- self.contexts = frozenset(contexts)
66
- self.alphabet = frozenset(symbol for counter in self.counts.values() for symbol in counter)
67
- self.distributions = {
68
- context: tuple((symbol, float(count) / total) for symbol, count in counter.items())
69
- for context, counter in self.counts.items()
70
- for total in (float(sum(counter.values())),)
71
- if total > 0.0
72
- }
73
- self._outgoing_cache: dict[Context, tuple[Edge, ...]] = {}
74
-
75
- @classmethod
76
- def from_sequences(
77
- cls,
78
- sequences: Iterable[Sequence[Symbol]],
79
- *,
80
- max_order: int,
81
- backoff_weight: float,
82
- start_state: Iterable[Symbol] | Context = (),
83
- track_order_weights: bool = False,
84
- ) -> "LazyBackoffContextModel":
85
- if max_order < 0:
86
- raise ValueError("max_order must be non-negative")
87
-
88
- counts: dict[Context, Counter[Symbol]] = defaultdict(Counter)
89
- for sequence in sequences:
90
- tokens = tuple(sequence)
91
- for index, symbol in enumerate(tokens):
92
- order_limit = min(max_order, index)
93
- for order in range(order_limit + 1):
94
- context = tokens[index - order : index] if order else ()
95
- counts[context][symbol] += 1
96
- return cls(
97
- counts,
98
- max_order=max_order,
99
- backoff_weight=backoff_weight,
100
- start_state=start_state,
101
- track_order_weights=track_order_weights,
102
- )
103
-
104
- def outgoing(self, context: Iterable[Symbol] | Context) -> tuple[Edge, ...]:
105
- state = _as_context(context)
106
- cached = self._outgoing_cache.get(state)
107
- if cached is not None:
108
- return cached
109
-
110
- if self.track_order_weights:
111
- edges = self._outgoing_with_order_weights(state)
112
- self._outgoing_cache[state] = edges
113
- return edges
114
-
115
- scores: dict[Symbol, float] = {}
116
- context_order = len(state)
117
- for order in range(context_order, -1, -1):
118
- suffix = state[-order:] if order else ()
119
- distribution = self.distributions.get(suffix)
120
- if not distribution:
121
- continue
122
- weight = self.backoff_weight ** (context_order - order)
123
- for symbol, probability in distribution:
124
- contribution = weight * probability
125
- scores[symbol] = scores.get(symbol, 0.0) + contribution
126
-
127
- total_score = float(sum(scores.values()))
128
- if total_score <= 0.0:
129
- edges: tuple[Edge, ...] = ()
130
- else:
131
- edges = tuple(
132
- Edge(
133
- symbol,
134
- score / total_score,
135
- ContextGraph._canon(state, symbol, self.contexts, self.max_order),
136
- )
137
- for symbol, score in scores.items()
138
- if score > 0.0
139
- )
140
- self._outgoing_cache[state] = edges
141
- return edges
142
-
143
- def _outgoing_with_order_weights(self, state: Context) -> tuple[Edge, ...]:
144
- scores: dict[Symbol, float] = {}
145
- order_scores: dict[Symbol, Counter[int]] = defaultdict(Counter)
146
- context_order = len(state)
147
- for order in range(context_order, -1, -1):
148
- suffix = state[-order:] if order else ()
149
- distribution = self.distributions.get(suffix)
150
- if not distribution:
151
- continue
152
- weight = self.backoff_weight ** (context_order - order)
153
- for symbol, probability in distribution:
154
- contribution = weight * probability
155
- scores[symbol] = scores.get(symbol, 0.0) + contribution
156
- order_scores[symbol][order] += contribution
157
-
158
- total_score = float(sum(scores.values()))
159
- if total_score <= 0.0:
160
- return ()
161
- return tuple(
162
- Edge(
163
- symbol,
164
- score / total_score,
165
- ContextGraph._canon(state, symbol, self.contexts, self.max_order),
166
- _normalized_order_weights(order_scores[symbol], score),
167
- )
168
- for symbol, score in scores.items()
169
- if score > 0.0
170
- )
171
-
172
- def prefix_context(self, prefix: Sequence[Symbol]) -> Context:
173
- for order in range(min(self.max_order, len(prefix)), -1, -1):
174
- suffix = tuple(prefix[-order:]) if order else ()
175
- if suffix in self.contexts:
176
- return suffix
177
- return self.start_state
178
-
179
- @property
180
- def materialized_edge_count(self) -> int:
181
- return sum(len(edges) for edges in self._outgoing_cache.values())
182
-
183
-
184
- @dataclass
185
- class PositionalBPResult:
186
- model: ContextModel
187
- length: int
188
- start_context: Context
189
- constraints: PositionConstraints
190
- betas: list[dict[Context, float]]
191
- allowed_edge_counts: list[dict[Context, int]]
192
- _transition_weight_cache: dict[tuple[int, Context], tuple[tuple[Edge, float], ...]] = field(
193
- default_factory=dict,
194
- init=False,
195
- repr=False,
196
- )
197
-
198
- @property
199
- def partition_function(self) -> float:
200
- return self.betas[0].get(self.start_context, 0.0)
201
-
202
- @property
203
- def unique_state_count(self) -> int:
204
- return len(set().union(*self.betas)) if self.betas else 0
205
-
206
- @property
207
- def time_indexed_state_count(self) -> int:
208
- return sum(len(layer) for layer in self.betas)
209
-
210
- @property
211
- def edge_count(self) -> int:
212
- return sum(sum(layer.values()) for layer in self.allowed_edge_counts)
213
-
214
- def transition_weights(self, time: int, context: Iterable[Symbol] | Context) -> tuple[tuple[Edge, float], ...]:
215
- if time < 0 or time >= self.length:
216
- raise ValueError("time is outside the BP horizon")
217
- state = _as_context(context)
218
- cache_key = (time, state)
219
- cached = self._transition_weight_cache.get(cache_key)
220
- if cached is not None:
221
- return cached
222
-
223
- beta_next = self.betas[time + 1]
224
- constraint = self.constraints.get(time)
225
- weighted = []
226
- for edge in self.model.outgoing(state):
227
- if not _allows(constraint, edge.symbol):
228
- continue
229
- weight = edge.probability * beta_next.get(edge.next_state, 0.0)
230
- if weight > 0.0:
231
- weighted.append((edge, weight))
232
- result = tuple(weighted)
233
- self._transition_weight_cache[cache_key] = result
234
- return result
235
-
236
- def _sample_edge(self, time: int, context: Context, generator: random.Random) -> Edge:
237
- weighted = self.transition_weights(time, context)
238
- total = sum(weight for _, weight in weighted)
239
- if total <= 0.0:
240
- raise RuntimeError("BP table contains no positive continuation for a reachable state")
241
- threshold = generator.random() * total
242
- cumulative = 0.0
243
- chosen = weighted[-1][0]
244
- for edge, weight in weighted:
245
- cumulative += weight
246
- if threshold <= cumulative:
247
- return edge
248
- return chosen
249
-
250
- def sample(self, *, rng: random.Random | int | None = None) -> tuple[Symbol, ...]:
251
- generator = _coerce_rng(rng)
252
- if self.partition_function <= 0.0:
253
- raise ValueError("cannot sample because the constrained partition function is zero")
254
-
255
- context = self.start_context
256
- output: list[Symbol] = []
257
- for time in range(self.length):
258
- edge = self._sample_edge(time, context, generator)
259
- output.append(edge.symbol)
260
- context = edge.next_state
261
- return tuple(output)
262
-
263
- def sample_many(self, count: int, *, rng: random.Random | int | None = None) -> list[tuple[Symbol, ...]]:
264
- generator = _coerce_rng(rng)
265
- return [self.sample(rng=generator) for _ in range(count)]
266
-
267
- def sample_with_orders(
268
- self,
269
- *,
270
- rng: random.Random | int | None = None,
271
- ) -> tuple[tuple[Symbol, ...], tuple[int, ...]]:
272
- generator = _coerce_rng(rng)
273
- if self.partition_function <= 0.0:
274
- raise ValueError("cannot sample because the constrained partition function is zero")
275
-
276
- context = self.start_context
277
- output: list[Symbol] = []
278
- orders: list[int] = []
279
- for time in range(self.length):
280
- edge = self._sample_edge(time, context, generator)
281
- output.append(edge.symbol)
282
- orders.append(_sample_order(edge.order_weights or ((len(context), 1.0),), generator))
283
- context = edge.next_state
284
- return tuple(output), tuple(orders)
285
-
286
- def conditional_probability(self, sequence: Sequence[Symbol]) -> float:
287
- if len(sequence) != self.length:
288
- raise ValueError("sequence length must match the BP horizon")
289
- if self.partition_function <= 0.0:
290
- return 0.0
291
-
292
- context = self.start_context
293
- probability = 1.0
294
- for time, symbol in enumerate(sequence):
295
- beta_now = self.betas[time].get(context, 0.0)
296
- if beta_now <= 0.0:
297
- return 0.0
298
- edge = next(
299
- (edge for edge, _ in self.transition_weights(time, context) if edge.symbol == symbol),
300
- None,
301
- )
302
- if edge is None:
303
- return 0.0
304
- weight = edge.probability * self.betas[time + 1].get(edge.next_state, 0.0)
305
- if weight <= 0.0:
306
- return 0.0
307
- probability *= weight / beta_now
308
- context = edge.next_state
309
- return probability
310
-
311
-
312
- def run_positional_bp(
313
- model: ContextModel,
314
- *,
315
- length: int,
316
- start_context: Iterable[Symbol] | Context | None = None,
317
- constraints: PositionConstraints | None = None,
318
- ) -> PositionalBPResult:
319
- """Run exact backward DP with time-indexed positional constraints."""
320
-
321
- if length < 0:
322
- raise ValueError("length must be non-negative")
323
- context0 = model.start_state if start_context is None else _as_context(start_context)
324
- position_constraints = dict(constraints or {})
325
- _validate_constraint_positions(position_constraints, length)
326
-
327
- betas: list[dict[Context, float]] = [dict() for _ in range(length + 1)]
328
- allowed_edge_counts: list[dict[Context, int]] = [dict() for _ in range(length)]
329
-
330
- def beta(time_index: int, context: Context) -> float:
331
- cached = betas[time_index].get(context)
332
- if cached is not None:
333
- return cached
334
- if time_index == length:
335
- betas[time_index][context] = 1.0
336
- return 1.0
337
-
338
- constraint = position_constraints.get(time_index)
339
- total = 0.0
340
- allowed_count = 0
341
- for edge in model.outgoing(context):
342
- if not _allows(constraint, edge.symbol):
343
- continue
344
- allowed_count += 1
345
- total += edge.probability * beta(time_index + 1, edge.next_state)
346
-
347
- allowed_edge_counts[time_index][context] = allowed_count
348
- betas[time_index][context] = total
349
- return total
350
-
351
- beta(0, context0)
352
- return PositionalBPResult(
353
- model=model,
354
- length=length,
355
- start_context=context0,
356
- constraints=position_constraints,
357
- betas=betas,
358
- allowed_edge_counts=allowed_edge_counts,
359
- )
360
-
361
-
362
- def _normalized_order_weights(
363
- order_scores: Counter[int],
364
- symbol_score: float,
365
- ) -> tuple[tuple[int, float], ...]:
366
- if symbol_score <= 0.0:
367
- return ()
368
- return tuple(
369
- sorted(
370
- ((order, contribution / symbol_score) for order, contribution in order_scores.items()),
371
- reverse=True,
372
- )
373
- )
374
-
375
-
376
- def _allows(constraint: PositionConstraint | None, symbol: Symbol) -> bool:
377
- if constraint is None:
378
- return True
379
- if callable(constraint):
380
- return bool(constraint(symbol))
381
- return symbol in constraint
382
-
383
-
384
- def _validate_constraint_positions(constraints: Mapping[int, PositionConstraint], length: int) -> None:
385
- for position in constraints:
386
- if position < 0 or position >= length:
387
- raise IndexError(f"constraint position {position} is outside length {length}")
388
-
389
-
390
- def _coerce_rng(rng: random.Random | int | None) -> random.Random:
391
- if isinstance(rng, random.Random):
392
- return rng
393
- return random.Random(rng)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/vo_regular_bp/vo_regular_bp/product_bp.py DELETED
@@ -1,304 +0,0 @@
1
- """Backward dynamic programming on reachable context-acceptor products."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass, field
6
- import random
7
- from typing import Hashable, Iterable, Sequence
8
-
9
- from .acceptors import DFA, transition_weight as regular_transition_weight
10
- from .context import Context, ContextGraph, Symbol, _as_context
11
-
12
- ProductState = tuple[Context, Hashable]
13
-
14
-
15
- @dataclass(frozen=True)
16
- class ProductEdge:
17
- symbol: Symbol
18
- probability: float
19
- transition_weight: float
20
- next_state: ProductState
21
- order_weights: tuple[tuple[int, float], ...] = ()
22
-
23
-
24
- @dataclass
25
- class ProductBPResult:
26
- graph: ContextGraph
27
- acceptor: DFA
28
- length: int
29
- start_state: ProductState
30
- layers: list[set[ProductState]]
31
- edges: list[dict[ProductState, tuple[ProductEdge, ...]]]
32
- betas: list[dict[ProductState, float]]
33
- _transition_weight_cache: dict[tuple[int, ProductState], tuple[tuple[ProductEdge, float], ...]] = field(
34
- default_factory=dict,
35
- init=False,
36
- repr=False,
37
- )
38
-
39
- @property
40
- def partition_function(self) -> float:
41
- return self.betas[0].get(self.start_state, 0.0)
42
-
43
- @property
44
- def unique_product_state_count(self) -> int:
45
- return len(set().union(*self.layers)) if self.layers else 0
46
-
47
- @property
48
- def time_indexed_product_state_count(self) -> int:
49
- return sum(len(layer) for layer in self.layers)
50
-
51
- @property
52
- def product_edge_count(self) -> int:
53
- return sum(len(edges) for layer_edges in self.edges for edges in layer_edges.values())
54
-
55
- @property
56
- def reachable_acceptor_state_count(self) -> int:
57
- return len({state for layer in self.layers for _, state in layer})
58
-
59
- def transition_weights(self, time: int, state: ProductState) -> tuple[tuple[ProductEdge, float], ...]:
60
- """Return outgoing product edges and their conditional sampling weights."""
61
-
62
- if time < 0 or time >= self.length:
63
- raise ValueError("time is outside the BP horizon")
64
- cache_key = (time, state)
65
- if cache_key in self._transition_weight_cache:
66
- return self._transition_weight_cache[cache_key]
67
-
68
- beta_next = self.betas[time + 1]
69
- weighted = []
70
- for edge in self.edges[time].get(state, ()):
71
- weight = (
72
- edge.probability
73
- * edge.transition_weight
74
- * beta_next.get(edge.next_state, 0.0)
75
- )
76
- if weight > 0.0:
77
- weighted.append((edge, weight))
78
- result = tuple(weighted)
79
- self._transition_weight_cache[cache_key] = result
80
- return result
81
-
82
- def _sample_edge(self, time: int, state: ProductState, generator: random.Random) -> ProductEdge:
83
- weighted = self.transition_weights(time, state)
84
- total = sum(weight for _, weight in weighted)
85
- if total <= 0.0:
86
- raise RuntimeError("BP table contains no positive continuation for a reachable state")
87
- threshold = generator.random() * total
88
- cumulative = 0.0
89
- chosen = weighted[-1][0]
90
- for edge, weight in weighted:
91
- cumulative += weight
92
- if threshold <= cumulative:
93
- return edge
94
- return chosen
95
-
96
- def sample(self, *, rng: random.Random | int | None = None) -> tuple[Symbol, ...]:
97
- """Draw one exact sample from the constrained distribution."""
98
-
99
- generator = _coerce_rng(rng)
100
- if self.partition_function <= 0.0:
101
- raise ValueError("cannot sample because the constrained partition function is zero")
102
-
103
- state = self.start_state
104
- output: list[Symbol] = []
105
- for time in range(self.length):
106
- chosen = self._sample_edge(time, state, generator)
107
- output.append(chosen.symbol)
108
- state = chosen.next_state
109
- return tuple(output)
110
-
111
- def sample_many(self, count: int, *, rng: random.Random | int | None = None) -> list[tuple[Symbol, ...]]:
112
- generator = _coerce_rng(rng)
113
- return [self.sample(rng=generator) for _ in range(count)]
114
-
115
- def sample_with_orders(
116
- self,
117
- *,
118
- rng: random.Random | int | None = None,
119
- ) -> tuple[tuple[Symbol, ...], tuple[int, ...]]:
120
- """Draw one exact sample and a latent order used at each event.
121
-
122
- For explicit backoff edges, the order is sampled from the posterior
123
- contribution of each suffix order to the selected symbol. For ordinary
124
- MLE edges without order metadata, the current context length is used.
125
- """
126
-
127
- generator = _coerce_rng(rng)
128
- if self.partition_function <= 0.0:
129
- raise ValueError("cannot sample because the constrained partition function is zero")
130
-
131
- state = self.start_state
132
- output: list[Symbol] = []
133
- orders: list[int] = []
134
- for time in range(self.length):
135
- chosen = self._sample_edge(time, state, generator)
136
- output.append(chosen.symbol)
137
- orders.append(_sample_order(chosen.order_weights, generator))
138
- state = chosen.next_state
139
- return tuple(output), tuple(orders)
140
-
141
- def sample_many_with_orders(
142
- self,
143
- count: int,
144
- *,
145
- rng: random.Random | int | None = None,
146
- ) -> list[tuple[tuple[Symbol, ...], tuple[int, ...]]]:
147
- generator = _coerce_rng(rng)
148
- return [self.sample_with_orders(rng=generator) for _ in range(count)]
149
-
150
- def conditional_probability(self, sequence: Sequence[Symbol]) -> float:
151
- """Probability of ``sequence`` under the constrained BP distribution."""
152
-
153
- if len(sequence) != self.length:
154
- raise ValueError("sequence length must match the BP horizon")
155
- if self.partition_function <= 0.0:
156
- return 0.0
157
-
158
- state = self.start_state
159
- probability = 1.0
160
- for time, symbol in enumerate(sequence):
161
- beta_now = self.betas[time].get(state, 0.0)
162
- if beta_now <= 0.0:
163
- return 0.0
164
- edge = next(
165
- (
166
- edge
167
- for edge in self.edges[time].get(state, ())
168
- if edge.symbol == symbol
169
- ),
170
- None,
171
- )
172
- if edge is None:
173
- return 0.0
174
- weight = (
175
- edge.probability
176
- * edge.transition_weight
177
- * self.betas[time + 1].get(edge.next_state, 0.0)
178
- )
179
- if weight <= 0.0:
180
- return 0.0
181
- probability *= weight / beta_now
182
- state = edge.next_state
183
- return probability
184
-
185
-
186
- def run_bp(
187
- graph: ContextGraph,
188
- acceptor: DFA,
189
- *,
190
- length: int,
191
- start_context: Iterable[Symbol] | Context | None = None,
192
- start_acceptor_state: Hashable | None = None,
193
- ) -> ProductBPResult:
194
- """Run exact backward DP on the reachable product for a fixed horizon."""
195
-
196
- if length < 0:
197
- raise ValueError("length must be non-negative")
198
-
199
- context0 = graph.start_state if start_context is None else _as_context(start_context)
200
- acceptor0 = acceptor.start_state if start_acceptor_state is None else start_acceptor_state
201
- start = (context0, acceptor0)
202
-
203
- layers: list[set[ProductState]] = [set([start])]
204
- edges_by_time: list[dict[ProductState, tuple[ProductEdge, ...]]] = []
205
-
206
- for _time in range(length):
207
- current_layer = layers[-1]
208
- next_layer: set[ProductState] = set()
209
- current_edges: dict[ProductState, tuple[ProductEdge, ...]] = {}
210
-
211
- for context_state, acceptor_state in current_layer:
212
- product_edges = []
213
- for edge in graph.outgoing(context_state):
214
- next_acceptor_state = acceptor.next_state(acceptor_state, edge.symbol)
215
- if next_acceptor_state is None:
216
- continue
217
- dfa_weight = regular_transition_weight(acceptor, acceptor_state, edge.symbol)
218
- if dfa_weight <= 0.0:
219
- continue
220
- next_product_state = (edge.next_state, next_acceptor_state)
221
- product_edges.append(
222
- ProductEdge(
223
- symbol=edge.symbol,
224
- probability=edge.probability,
225
- transition_weight=dfa_weight,
226
- next_state=next_product_state,
227
- order_weights=edge.order_weights or ((len(context_state), 1.0),),
228
- )
229
- )
230
- next_layer.add(next_product_state)
231
- current_edges[(context_state, acceptor_state)] = tuple(product_edges)
232
-
233
- edges_by_time.append(current_edges)
234
- layers.append(next_layer)
235
-
236
- betas: list[dict[ProductState, float]] = [dict() for _ in range(length + 1)]
237
- betas[length] = {
238
- state: 1.0 if acceptor.is_accepting(state[1]) else 0.0
239
- for state in layers[length]
240
- }
241
-
242
- for time in range(length - 1, -1, -1):
243
- beta_next = betas[time + 1]
244
- beta_now: dict[ProductState, float] = {}
245
- for state in layers[time]:
246
- beta_now[state] = sum(
247
- edge.probability
248
- * edge.transition_weight
249
- * beta_next.get(edge.next_state, 0.0)
250
- for edge in edges_by_time[time].get(state, ())
251
- )
252
- betas[time] = beta_now
253
-
254
- return ProductBPResult(
255
- graph=graph,
256
- acceptor=acceptor,
257
- length=length,
258
- start_state=start,
259
- layers=layers,
260
- edges=edges_by_time,
261
- betas=betas,
262
- )
263
-
264
-
265
- def sample_exact(
266
- graph: ContextGraph,
267
- acceptor: DFA,
268
- *,
269
- length: int,
270
- rng: random.Random | int | None = None,
271
- start_context: Iterable[Symbol] | Context | None = None,
272
- start_acceptor_state: Hashable | None = None,
273
- ) -> tuple[Symbol, ...]:
274
- """Convenience wrapper: run BP and draw one exact sample."""
275
-
276
- result = run_bp(
277
- graph,
278
- acceptor,
279
- length=length,
280
- start_context=start_context,
281
- start_acceptor_state=start_acceptor_state,
282
- )
283
- return result.sample(rng=rng)
284
-
285
-
286
- def _coerce_rng(rng: random.Random | int | None) -> random.Random:
287
- if rng is None:
288
- return random.Random()
289
- if isinstance(rng, int):
290
- return random.Random(rng)
291
- return rng
292
-
293
-
294
- def _sample_order(order_weights: tuple[tuple[int, float], ...], rng: random.Random) -> int:
295
- if not order_weights:
296
- return 0
297
- threshold = rng.random() * sum(weight for _, weight in order_weights)
298
- cumulative = 0.0
299
- chosen = order_weights[-1][0]
300
- for order, weight in order_weights:
301
- cumulative += weight
302
- if threshold <= cumulative:
303
- return order
304
- return chosen