| """Tiny grammar engine for acrostic-style constraints. |
| |
| A faithful Python port of src/grammar.js. |
| |
| Primitives: |
| - Atoms: {"kind": "lit", "allowed": set[str]} (consumes exactly one char from |
| `allowed`) or {"kind": "body", "max": int} (consumes 0..max non-newline |
| chars). |
| - Atom sequences are concatenation-only; with the body/newline structure we |
| use, transitions are deterministic, so state packs into one int: |
| atom_idx * stride + count. |
| |
| Builders: |
| - compile_acrostic(secret, ...) — list-mode or prose-mode acrostic. |
| - compile_literal(text) — exact-text matcher (used by the classifier). |
| - union_grammars([g1, g2, ...]) — accept if any branch is alive. |
| |
| The dead-state sentinel is -1 everywhere, matching the JS original. |
| """ |
|
|
| from __future__ import annotations |
|
|
| |
| |
| |
| |
| PUNCT_FOR_SPACE = set( |
| list(".,;:!?-") |
| + list("()[]{}") |
| + list("~<>") |
| + ['"', "'", "`"] |
| + list("@#$%&+=/\\|_^") |
| ) |
|
|
|
|
| class AtomGrammar: |
| """A single concatenation-only atom-sequence grammar (an NFA packed into ints).""" |
|
|
| def __init__(self, atoms): |
| self.atoms = atoms |
| max_body_max = 0 |
| for a in atoms: |
| if a["kind"] == "body" and a["max"] > max_body_max: |
| max_body_max = a["max"] |
| self.stride = max_body_max + 2 |
| self.PAST_END = len(atoms) * self.stride |
| self.state_count = self.PAST_END + 1 |
|
|
| |
| |
| accepting = bytearray(self.state_count) |
| accepting[self.PAST_END] = 1 |
| next_accepting = True |
| for a in range(len(atoms) - 1, -1, -1): |
| atom = atoms[a] |
| if next_accepting: |
| mn = 1 if atom["kind"] == "lit" else 0 |
| mx = 1 if atom["kind"] == "lit" else atom["max"] |
| for c in range(mn, mx + 1): |
| accepting[a * self.stride + c] = 1 |
| next_accepting = accepting[a * self.stride + 0] == 1 |
| self.accepting = accepting |
|
|
| self.initial = 0 |
|
|
| def _consume_at(self, a, ch): |
| atoms = self.atoms |
| while a < len(atoms): |
| atom = atoms[a] |
| if atom["kind"] == "lit": |
| if ch in atom["allowed"]: |
| return self.PAST_END if a + 1 >= len(atoms) else (a + 1) * self.stride |
| return -1 |
| if ch != "\n": |
| return a * self.stride + 1 |
| a += 1 |
| return -1 |
|
|
| def advance(self, state, s): |
| stride = self.stride |
| atoms = self.atoms |
| cur = state |
| for ch in s: |
| if cur == self.PAST_END: |
| return -1 |
| a = cur // stride |
| c = cur - a * stride |
| atom = atoms[a] |
| if atom["kind"] == "lit": |
| if c < 1 and ch in atom["allowed"]: |
| nxt = self.PAST_END if a + 1 >= len(atoms) else (a + 1) * stride |
| else: |
| return -1 |
| else: |
| if c < atom["max"] and ch != "\n": |
| nxt = a * stride + (c + 1) |
| else: |
| nxt = self._consume_at(a + 1, ch) |
| if nxt == -1: |
| return -1 |
| cur = nxt |
| return cur |
|
|
| def accepts(self, state): |
| return state is not None and 0 <= state < self.state_count and self.accepting[state] == 1 |
|
|
|
|
| def compile_acrostic(secret, list_prefix=" * ", max_line=80, case_insensitive=False, first_line_prefix=True): |
| if not secret: |
| raise ValueError("secret must be non-empty") |
| atoms = [] |
| for i, letter in enumerate(secret): |
| want_prefix = i > 0 or first_line_prefix |
| if want_prefix: |
| for c in list_prefix: |
| atoms.append({"kind": "lit", "allowed": {c}}) |
| if letter == " ": |
| allowed = set(PUNCT_FOR_SPACE) |
| elif case_insensitive: |
| allowed = {letter.upper(), letter.lower()} |
| else: |
| allowed = {letter} |
| atoms.append({"kind": "lit", "allowed": allowed}) |
| atoms.append({"kind": "body", "max": max_line}) |
| if i < len(secret) - 1: |
| atoms.append({"kind": "lit", "allowed": {"\n"}}) |
| return AtomGrammar(atoms) |
|
|
|
|
| def compile_literal(text): |
| if not text: |
| raise ValueError("literal must be non-empty") |
| atoms = [{"kind": "lit", "allowed": {c}} for c in text] |
| return AtomGrammar(atoms) |
|
|
|
|
| class UnionGrammar: |
| """Run several grammars in parallel; a token is alive iff at least one branch |
| is alive. State is a list of per-branch ints (-1 = dead branch). When every |
| branch is dead, advance returns -1 (the single-grammar dead sentinel).""" |
|
|
| def __init__(self, grammars): |
| self.grammars = grammars |
| self.initial = [g.initial for g in grammars] |
|
|
| def advance(self, state, s): |
| nxt = [-1] * len(self.grammars) |
| any_live = False |
| for i, g in enumerate(self.grammars): |
| if state[i] == -1: |
| nxt[i] = -1 |
| continue |
| r = g.advance(state[i], s) |
| nxt[i] = r |
| if r != -1: |
| any_live = True |
| return nxt if any_live else -1 |
|
|
| def accepts(self, state): |
| if state == -1: |
| return False |
| for i, g in enumerate(self.grammars): |
| if state[i] != -1 and g.accepts(state[i]): |
| return True |
| return False |
|
|
|
|
| def union_grammars(grammars): |
| return UnionGrammar(grammars) |
|
|