quire-grammar 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. quire_grammar-0.2.0/LICENSE +21 -0
  2. quire_grammar-0.2.0/PKG-INFO +121 -0
  3. quire_grammar-0.2.0/README.md +95 -0
  4. quire_grammar-0.2.0/pyproject.toml +64 -0
  5. quire_grammar-0.2.0/setup.cfg +4 -0
  6. quire_grammar-0.2.0/src/quire_grammar/__init__.py +17 -0
  7. quire_grammar-0.2.0/src/quire_grammar/__main__.py +57 -0
  8. quire_grammar-0.2.0/src/quire_grammar/check.py +56 -0
  9. quire_grammar-0.2.0/src/quire_grammar/edits.py +241 -0
  10. quire_grammar-0.2.0/src/quire_grammar/model.py +245 -0
  11. quire_grammar-0.2.0/src/quire_grammar/py.typed +0 -0
  12. quire_grammar-0.2.0/src/quire_grammar/registry.py +45 -0
  13. quire_grammar-0.2.0/src/quire_grammar/rules/__init__.py +11 -0
  14. quire_grammar-0.2.0/src/quire_grammar/rules/agreement.py +99 -0
  15. quire_grammar-0.2.0/src/quire_grammar/rules/confusables.py +200 -0
  16. quire_grammar-0.2.0/src/quire_grammar/rules/mechanics.py +142 -0
  17. quire_grammar-0.2.0/src/quire_grammar/rules/punctuation.py +130 -0
  18. quire_grammar-0.2.0/src/quire_grammar/rules/style.py +144 -0
  19. quire_grammar-0.2.0/src/quire_grammar/segment.py +35 -0
  20. quire_grammar-0.2.0/src/quire_grammar/types.py +41 -0
  21. quire_grammar-0.2.0/src/quire_grammar.egg-info/PKG-INFO +121 -0
  22. quire_grammar-0.2.0/src/quire_grammar.egg-info/SOURCES.txt +34 -0
  23. quire_grammar-0.2.0/src/quire_grammar.egg-info/dependency_links.txt +1 -0
  24. quire_grammar-0.2.0/src/quire_grammar.egg-info/entry_points.txt +2 -0
  25. quire_grammar-0.2.0/src/quire_grammar.egg-info/requires.txt +18 -0
  26. quire_grammar-0.2.0/src/quire_grammar.egg-info/top_level.txt +1 -0
  27. quire_grammar-0.2.0/tests/test_build_dataset.py +89 -0
  28. quire_grammar-0.2.0/tests/test_check.py +44 -0
  29. quire_grammar-0.2.0/tests/test_corrupt.py +94 -0
  30. quire_grammar-0.2.0/tests/test_edits.py +93 -0
  31. quire_grammar-0.2.0/tests/test_eval.py +65 -0
  32. quire_grammar-0.2.0/tests/test_labels.py +32 -0
  33. quire_grammar-0.2.0/tests/test_model.py +113 -0
  34. quire_grammar-0.2.0/tests/test_model_integration.py +64 -0
  35. quire_grammar-0.2.0/tests/test_prepare_corpus.py +97 -0
  36. quire_grammar-0.2.0/tests/test_rules.py +149 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roger Cooper
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.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: quire-grammar
3
+ Version: 0.2.0
4
+ Summary: An offline English grammar and style checker — a writer's aid, not a proofreading service.
5
+ Author: Roger Cooper
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: model
11
+ Requires-Dist: quire-grammar-model>=0.2; extra == "model"
12
+ Requires-Dist: onnxruntime>=1.17; extra == "model"
13
+ Requires-Dist: tokenizers>=0.15; extra == "model"
14
+ Provides-Extra: ml
15
+ Requires-Dist: quire-grammar-model>=0.2; extra == "ml"
16
+ Requires-Dist: onnxruntime>=1.17; extra == "ml"
17
+ Requires-Dist: tokenizers>=0.15; extra == "ml"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8; extra == "dev"
20
+ Requires-Dist: ruff>=0.6; extra == "dev"
21
+ Requires-Dist: mypy>=1.8; extra == "dev"
22
+ Requires-Dist: onnxruntime>=1.17; extra == "dev"
23
+ Requires-Dist: tokenizers>=0.15; extra == "dev"
24
+ Requires-Dist: numpy>=1.26; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # quire-grammar
28
+
29
+ A **standalone, offline English grammar and style checker** — a writer's aid,
30
+ not a proofreading service, and a library other writing tools can license
31
+ rather than build their own. It runs fully offline and is permissively
32
+ licensed end to end.
33
+
34
+ [Quire Desktop](https://github.com/jutreuter/quire-desktop) is the first
35
+ consumer — the rules layer is a hard dependency (the always-on checker) and
36
+ the model is the opt-in `[model]` extra, *off if missing* the way WordNet and
37
+ SCOWL spell-check are. The dependency runs one way and the `check()` API is
38
+ designed to stand on its own.
39
+
40
+ ## Use it
41
+
42
+ ```python
43
+ from quire_grammar import check
44
+
45
+ for c in check("Its been a long day and and the sky is is grey."):
46
+ print(c.rule_id, repr(c.original), "->", c.suggestions, f"({c.confidence})")
47
+ ```
48
+
49
+ ```
50
+ $ quire-grammar "She had a apple. This went better then that."
51
+ 1:9 [mechanics/a-vs-an] “a” before “apple” — “an” matches the sound. → 'an'
52
+ 1:35 [confusable/then-vs-than] “then” here reads as “than”. → 'than'
53
+ ```
54
+
55
+ `check()` returns a sorted `list[Correction]` — a stable dataclass of
56
+ `start, end, original, category, rule_id, message, suggestions, confidence`.
57
+ That signature does not change whether or not the model is installed.
58
+
59
+ ## Install
60
+
61
+ ```
62
+ pip install quire-grammar # rules layer — pure Python, zero deps
63
+ pip install quire-grammar[model] # + the phase-2 model (onnxruntime, tokenizers, ~40 MB weights)
64
+ ```
65
+
66
+ Without the `[model]` extra, `check()` runs rules-only and the model
67
+ contributes nothing — the same "off if missing" behaviour Quire gives WordNet
68
+ and spell-check.
69
+
70
+ ## How it works
71
+
72
+ **Phase 1 — a deterministic rules layer (this repo, now).** Pure-Python,
73
+ **zero runtime dependencies**. Each rule is a narrow, high-precision pattern
74
+ for a mistake a *native writer* actually makes. The guard rail is precision:
75
+ a checker that cries wolf gets switched off, so the test suite weighs "must
76
+ not flag" cases as heavily as "must flag".
77
+
78
+ | category | rules |
79
+ |---|---|
80
+ | **mechanics** | doubled word · `a`/`an` · space before punctuation · missing space after a sentence · capitalisation after `.` |
81
+ | **confusable** | its/it's · there/their/they're · your/you're · then/than · affect/effect · lose/loose · lead/led · breath/breathe · to/too · whose/who's · passed/past · weather/whether · "should of" → "should have" |
82
+ | **agreement** | pronoun subject–verb (`he don't`, `they was`, `I are`) · within-sentence past → present tense drift |
83
+ | **punctuation** | comma splice · broken-off subordinate-clause fragment · straight-vs-curly quotes/apostrophes/dashes *(opt-in)* |
84
+ | **style** | filter verb · `said angrily` · overlong sentence · dangling modifier · weak intensifier *(opt-in)* · passive voice *(opt-in)* |
85
+
86
+ `enable` / `disable` take rule ids or whole categories. Opt-in rules are off
87
+ until you ask for them.
88
+
89
+ **Phase 2 — a small local model behind the same `check()` API.** A
90
+ GECToR-style edit tagger (DistilBERT-base-cased, 66 MB int8 ONNX, ~40 MB in
91
+ the wheel) run on CPU via `onnxruntime` — no GPU is assumed, for training or
92
+ inference. It catches homophone and agreement slips the deliberately-narrow
93
+ rules miss, gated at a 0.9 confidence floor (tuned so it flags ~1% of
94
+ genuinely clean sentences), merged with the rule hits so a rule wins on any
95
+ span overlap. Trained **synthetic-first** — `corrupt.py` errors over
96
+ public-domain prose, because the usual GEC learner corpora are
97
+ research/non-commercial licensed. It ships as the separate
98
+ [`quire-grammar-model`](packages/quire-grammar-model/) distribution
99
+ (`quire-grammar[model]`); training code and data pipeline live in `train/`
100
+ and are **not** shipped.
101
+
102
+ See [CLAUDE.md](CLAUDE.md) for the design contract and the phase-2 plan.
103
+
104
+ ## Develop
105
+
106
+ ```bash
107
+ python -m venv .venv
108
+ .venv/bin/pip install -e ".[dev]" -e ./packages/quire-grammar-model
109
+ .venv/bin/python -m pytest -q
110
+ .venv/bin/ruff check src train tests packages
111
+ .venv/bin/mypy
112
+ ```
113
+
114
+ The phase-2 training scripts in `train/` need the heavier ML stack —
115
+ `.venv/bin/pip install -r train/requirements.txt` (CPU-only, all permissive).
116
+ It is **not** a dependency of the shipped package.
117
+
118
+ `quire-grammar` is MIT; `quire-grammar-model` is Apache-2.0 (it carries a
119
+ `distilbert-base-cased` lineage). Everything either depends on — now and
120
+ planned — is MIT / BSD / Apache. No GPL, no AGPL, so a licensee can
121
+ redistribute the whole thing.
@@ -0,0 +1,95 @@
1
+ # quire-grammar
2
+
3
+ A **standalone, offline English grammar and style checker** — a writer's aid,
4
+ not a proofreading service, and a library other writing tools can license
5
+ rather than build their own. It runs fully offline and is permissively
6
+ licensed end to end.
7
+
8
+ [Quire Desktop](https://github.com/jutreuter/quire-desktop) is the first
9
+ consumer — the rules layer is a hard dependency (the always-on checker) and
10
+ the model is the opt-in `[model]` extra, *off if missing* the way WordNet and
11
+ SCOWL spell-check are. The dependency runs one way and the `check()` API is
12
+ designed to stand on its own.
13
+
14
+ ## Use it
15
+
16
+ ```python
17
+ from quire_grammar import check
18
+
19
+ for c in check("Its been a long day and and the sky is is grey."):
20
+ print(c.rule_id, repr(c.original), "->", c.suggestions, f"({c.confidence})")
21
+ ```
22
+
23
+ ```
24
+ $ quire-grammar "She had a apple. This went better then that."
25
+ 1:9 [mechanics/a-vs-an] “a” before “apple” — “an” matches the sound. → 'an'
26
+ 1:35 [confusable/then-vs-than] “then” here reads as “than”. → 'than'
27
+ ```
28
+
29
+ `check()` returns a sorted `list[Correction]` — a stable dataclass of
30
+ `start, end, original, category, rule_id, message, suggestions, confidence`.
31
+ That signature does not change whether or not the model is installed.
32
+
33
+ ## Install
34
+
35
+ ```
36
+ pip install quire-grammar # rules layer — pure Python, zero deps
37
+ pip install quire-grammar[model] # + the phase-2 model (onnxruntime, tokenizers, ~40 MB weights)
38
+ ```
39
+
40
+ Without the `[model]` extra, `check()` runs rules-only and the model
41
+ contributes nothing — the same "off if missing" behaviour Quire gives WordNet
42
+ and spell-check.
43
+
44
+ ## How it works
45
+
46
+ **Phase 1 — a deterministic rules layer (this repo, now).** Pure-Python,
47
+ **zero runtime dependencies**. Each rule is a narrow, high-precision pattern
48
+ for a mistake a *native writer* actually makes. The guard rail is precision:
49
+ a checker that cries wolf gets switched off, so the test suite weighs "must
50
+ not flag" cases as heavily as "must flag".
51
+
52
+ | category | rules |
53
+ |---|---|
54
+ | **mechanics** | doubled word · `a`/`an` · space before punctuation · missing space after a sentence · capitalisation after `.` |
55
+ | **confusable** | its/it's · there/their/they're · your/you're · then/than · affect/effect · lose/loose · lead/led · breath/breathe · to/too · whose/who's · passed/past · weather/whether · "should of" → "should have" |
56
+ | **agreement** | pronoun subject–verb (`he don't`, `they was`, `I are`) · within-sentence past → present tense drift |
57
+ | **punctuation** | comma splice · broken-off subordinate-clause fragment · straight-vs-curly quotes/apostrophes/dashes *(opt-in)* |
58
+ | **style** | filter verb · `said angrily` · overlong sentence · dangling modifier · weak intensifier *(opt-in)* · passive voice *(opt-in)* |
59
+
60
+ `enable` / `disable` take rule ids or whole categories. Opt-in rules are off
61
+ until you ask for them.
62
+
63
+ **Phase 2 — a small local model behind the same `check()` API.** A
64
+ GECToR-style edit tagger (DistilBERT-base-cased, 66 MB int8 ONNX, ~40 MB in
65
+ the wheel) run on CPU via `onnxruntime` — no GPU is assumed, for training or
66
+ inference. It catches homophone and agreement slips the deliberately-narrow
67
+ rules miss, gated at a 0.9 confidence floor (tuned so it flags ~1% of
68
+ genuinely clean sentences), merged with the rule hits so a rule wins on any
69
+ span overlap. Trained **synthetic-first** — `corrupt.py` errors over
70
+ public-domain prose, because the usual GEC learner corpora are
71
+ research/non-commercial licensed. It ships as the separate
72
+ [`quire-grammar-model`](packages/quire-grammar-model/) distribution
73
+ (`quire-grammar[model]`); training code and data pipeline live in `train/`
74
+ and are **not** shipped.
75
+
76
+ See [CLAUDE.md](CLAUDE.md) for the design contract and the phase-2 plan.
77
+
78
+ ## Develop
79
+
80
+ ```bash
81
+ python -m venv .venv
82
+ .venv/bin/pip install -e ".[dev]" -e ./packages/quire-grammar-model
83
+ .venv/bin/python -m pytest -q
84
+ .venv/bin/ruff check src train tests packages
85
+ .venv/bin/mypy
86
+ ```
87
+
88
+ The phase-2 training scripts in `train/` need the heavier ML stack —
89
+ `.venv/bin/pip install -r train/requirements.txt` (CPU-only, all permissive).
90
+ It is **not** a dependency of the shipped package.
91
+
92
+ `quire-grammar` is MIT; `quire-grammar-model` is Apache-2.0 (it carries a
93
+ `distilbert-base-cased` lineage). Everything either depends on — now and
94
+ planned — is MIT / BSD / Apache. No GPL, no AGPL, so a licensee can
95
+ redistribute the whole thing.
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "quire-grammar"
7
+ version = "0.2.0"
8
+ description = "An offline English grammar and style checker — a writer's aid, not a proofreading service."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Roger Cooper" }]
13
+ # The rules layer has ZERO runtime dependencies. Keep it that way.
14
+ dependencies = []
15
+
16
+ [project.optional-dependencies]
17
+ # Phase 2 — the small local model behind the same check() API. `model` pulls
18
+ # the ONNX weights (the separate quire-grammar-model dist) plus the runtime.
19
+ model = ["quire-grammar-model>=0.2", "onnxruntime>=1.17", "tokenizers>=0.15"]
20
+ ml = ["quire-grammar-model>=0.2", "onnxruntime>=1.17", "tokenizers>=0.15"] # deprecated alias for `model`
21
+ # dev pulls the model runtime too (onnxruntime/tokenizers/numpy) so the full
22
+ # test suite — including the ONNX integration tests — runs after
23
+ # pip install -e ".[dev]" -e ./packages/quire-grammar-model
24
+ dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.8",
25
+ "onnxruntime>=1.17", "tokenizers>=0.15", "numpy>=1.26"]
26
+
27
+ [project.scripts]
28
+ quire-grammar = "quire_grammar.__main__:main"
29
+
30
+ [tool.setuptools]
31
+ package-dir = { "" = "src" }
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.setuptools.package-data]
37
+ # py.typed marks the package as typed; models/* is the optional local-build
38
+ # fallback (the real model ships as the separate quire-grammar-model dist)
39
+ quire_grammar = ["py.typed", "models/*"]
40
+
41
+ [tool.pytest.ini_options]
42
+ # "src" for the package, "packages/.../src" so the model dist is importable
43
+ # without installing it, "." for the unshipped train/ package
44
+ pythonpath = ["src", "packages/quire-grammar-model/src", "."]
45
+ testpaths = ["tests"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py311"
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "W", "I", "B", "UP", "RUF"]
53
+ ignore = ["RUF001"] # curly quotes / “ ” are deliberate in message text
54
+
55
+ [tool.mypy]
56
+ files = ["src/quire_grammar", "train", "packages/quire-grammar-model/src"]
57
+ mypy_path = "packages/quire-grammar-model/src"
58
+
59
+ # train/train.py and train/export.py are workbench-only and import the heavy
60
+ # ML stack from train/requirements.txt, which isn't installed for lint/CI.
61
+ [[tool.mypy.overrides]]
62
+ module = ["torch.*", "transformers.*", "datasets.*", "numpy.*", "onnx.*",
63
+ "onnxruntime.*", "tokenizers.*"]
64
+ ignore_missing_imports = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ """quire-grammar — an offline English grammar and style checker.
2
+
3
+ Public surface (stable):
4
+
5
+ check(text, *, enable=None, disable=None, min_confidence=0.0) -> list[Correction]
6
+
7
+ Everything else is an implementation detail. The phase-2 local model plugs in
8
+ behind ``check`` without changing this signature.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .check import check
14
+ from .types import CATEGORIES, Correction
15
+
16
+ __version__ = "0.0.1"
17
+ __all__ = ["CATEGORIES", "Correction", "__version__", "check"]
@@ -0,0 +1,57 @@
1
+ """quire-grammar CLI:
2
+
3
+ quire-grammar "Its a nice day and and the the sky is is blue."
4
+ echo "text" | quire-grammar -
5
+ quire-grammar --json chapter-3.txt
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+
14
+ from . import __version__, check
15
+
16
+
17
+ def _line_col(text: str, pos: int) -> tuple[int, int]:
18
+ head = text[:pos]
19
+ return head.count("\n") + 1, pos - (head.rfind("\n") + 1) + 1
20
+
21
+
22
+ def main(argv: list[str] | None = None) -> int:
23
+ ap = argparse.ArgumentParser(prog="quire-grammar", description=__doc__)
24
+ ap.add_argument("source", help='text, a file path, or "-" for stdin')
25
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
26
+ ap.add_argument("--min-confidence", type=float, default=0.0)
27
+ ap.add_argument("--disable", default="", help="comma-separated rule ids / categories")
28
+ ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
29
+ args = ap.parse_args(argv)
30
+
31
+ if args.source == "-":
32
+ text = sys.stdin.read()
33
+ else:
34
+ try:
35
+ with open(args.source, encoding="utf-8") as f:
36
+ text = f.read()
37
+ except OSError:
38
+ text = args.source
39
+
40
+ hits = check(text, disable=[s for s in args.disable.split(",") if s],
41
+ min_confidence=args.min_confidence)
42
+
43
+ if args.json:
44
+ print(json.dumps([vars(h) | {"meta": h.meta} for h in hits], indent=2))
45
+ return 1 if hits else 0
46
+
47
+ for h in hits:
48
+ line, col = _line_col(text, h.start)
49
+ fix = f" → {h.suggestions[0]!r}" if h.suggestions else ""
50
+ print(f"{line}:{col} [{h.category}/{h.rule_id}] {h.message}{fix}")
51
+ if not hits:
52
+ print("no issues found")
53
+ return 1 if hits else 0
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -0,0 +1,56 @@
1
+ """The one call the rest of the world makes.
2
+
3
+ from quire_grammar import check
4
+ for c in check("Its a nice day and and the the sky is is blue."):
5
+ print(c.rule_id, c.original, "->", c.suggestions)
6
+
7
+ ``enable`` / ``disable`` accept rule ids *or* category names, so a UI can
8
+ switch off "style" wholesale or turn on a single opt-in rule.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterable
14
+
15
+ from . import rules as _rules # noqa: F401 — importing registers every rule
16
+ from .registry import all_rules
17
+ from .types import Correction
18
+
19
+ # Phase 2: when the optional model is installed, its predictions are merged
20
+ # in here (same Correction shape, category "agreement" etc.), de-duplicated
21
+ # against the rule hits, and gated by a confidence threshold.
22
+ _model_predict: Callable[[str], list[Correction]] | None
23
+ try: # pragma: no cover
24
+ from .model import predict as _model_predict
25
+ except Exception: # pragma: no cover
26
+ _model_predict = None
27
+
28
+
29
+ def check(
30
+ text: str,
31
+ *,
32
+ enable: Iterable[str] | None = None,
33
+ disable: Iterable[str] | None = None,
34
+ min_confidence: float = 0.0,
35
+ ) -> list[Correction]:
36
+ on = set(enable or ())
37
+ off = set(disable or ())
38
+
39
+ out: list[Correction] = []
40
+ for r in all_rules():
41
+ keys = {r.id, r.category}
42
+ active = bool(keys & on) or (r.enabled_by_default and not (keys & off))
43
+ if active:
44
+ out.extend(r.fn(text))
45
+
46
+ if _model_predict is not None and "model" not in off: # pragma: no cover
47
+ rule_spans = [(c.start, c.end) for c in out]
48
+ for c in _model_predict(text):
49
+ # a rule hit wins on any span overlap — it's the higher-precision
50
+ # signal; the model only adds what the rules missed
51
+ if not any(c.start < re_ and rs < c.end for rs, re_ in rule_spans):
52
+ out.append(c)
53
+
54
+ out = [c for c in out if c.confidence >= min_confidence]
55
+ out.sort(key=lambda c: (c.start, c.end, c.rule_id))
56
+ return out
@@ -0,0 +1,241 @@
1
+ """The GECToR-style edit-tag scheme — shared by the phase-2 model's training
2
+ pipeline and its runtime decoder, so it lives in the shipped package.
3
+
4
+ The model reads a sentence, splits it into word tokens, and predicts one
5
+ **edit tag** per token. Applying the tags and re-joining rebuilds the
6
+ corrected sentence.
7
+
8
+ $KEEP leave the token alone
9
+ $DELETE drop the token
10
+ $REPLACE_{t} swap the token for t
11
+ $APPEND_{t} insert t (one or more words) after the token
12
+ $CASE_CAPITAL Titlecase the token
13
+ $CASE_LOWER lowercase the token
14
+ $CASE_UPPER UPPERCASE the token
15
+
16
+ Kept small on purpose — precision over coverage. Multi-edit tokens (GECToR
17
+ uses iterative rounds) are rare in native-writer text; ``$APPEND_{t}`` carries
18
+ the whole inserted run so one pass covers them. Anything the scheme cannot
19
+ express round-trips wrong, and :func:`encode`'s caller drops the pair.
20
+
21
+ Zero dependencies — part of the rules-layer contract.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass
28
+
29
+ KEEP = "$KEEP"
30
+ DELETE = "$DELETE"
31
+ _REPLACE = "$REPLACE_"
32
+ _APPEND = "$APPEND_"
33
+ CASE_CAPITAL = "$CASE_CAPITAL"
34
+ CASE_LOWER = "$CASE_LOWER"
35
+ CASE_UPPER = "$CASE_UPPER"
36
+
37
+ _CASE_TAGS = (CASE_CAPITAL, CASE_LOWER, CASE_UPPER)
38
+
39
+ #: the tags that are always in the vocabulary, at fixed indices
40
+ BASE_TAGS: tuple[str, ...] = (KEEP, DELETE, *_CASE_TAGS)
41
+
42
+
43
+ def replace_tag(token: str) -> str:
44
+ return _REPLACE + token
45
+
46
+
47
+ def append_tag(run: str) -> str:
48
+ return _APPEND + run
49
+
50
+
51
+ def is_replace(tag: str) -> bool:
52
+ return tag.startswith(_REPLACE)
53
+
54
+
55
+ def is_append(tag: str) -> bool:
56
+ return tag.startswith(_APPEND)
57
+
58
+
59
+ def tag_value(tag: str) -> str:
60
+ """The payload of a ``$REPLACE_`` / ``$APPEND_`` tag."""
61
+ return tag.split("_", 1)[1]
62
+
63
+
64
+ # --------------------------------------------------------------------------
65
+ # tokenisation — word tokens with punctuation split off, plus a deterministic
66
+ # detokeniser. Not exact for pathological spacing; callers verify and drop.
67
+ # --------------------------------------------------------------------------
68
+
69
+ _TOKEN_RE = re.compile(
70
+ r"""
71
+ \d[\d,]*(?:\.\d+)? # 12 1,000 3.5
72
+ | [A-Za-z]+(?:['’][A-Za-z]+)* # word, with internal apostrophes
73
+ | \.\.\.|[.!?]+ # sentence punctuation, runs kept together
74
+ | --+|[-–—] # dashes / hyphen
75
+ | [^\s\w] # any other single punctuation mark
76
+ """,
77
+ re.VERBOSE,
78
+ )
79
+
80
+ # marks that hug the previous token (no space before)
81
+ _CLOSE = set(".,;:!?)]}%…") | {"...", "n't", "'s", "'re", "'ve", "'ll", "'m", "'d"}
82
+ # marks that hug the next token (no space after)
83
+ _OPEN = set("([{$")
84
+ _CONTRACTION = re.compile(r"^['’](s|re|ve|ll|m|d)$|^n['’]t$", re.I)
85
+
86
+
87
+ def tokenize(text: str) -> list[str]:
88
+ return _TOKEN_RE.findall(text)
89
+
90
+
91
+ def tokenize_spans(text: str) -> list[tuple[str, int, int]]:
92
+ """``(token, start, end)`` — same tokens as :func:`tokenize`, with their
93
+ character offsets in ``text`` (the runtime decoder needs them to place
94
+ ``Correction`` spans)."""
95
+ return [(m.group(0), m.start(), m.end()) for m in _TOKEN_RE.finditer(text)]
96
+
97
+
98
+ def detokenize(tokens: list[str]) -> str:
99
+ """Join word tokens back into a sentence. Deterministic; assumes the
100
+ tokens describe ordinary prose."""
101
+ out: list[str] = []
102
+ quote_open = False
103
+ for tok in tokens:
104
+ if not out:
105
+ out.append(tok)
106
+ continue
107
+ prev = out[-1]
108
+ glue = (
109
+ tok in _CLOSE
110
+ or _CONTRACTION.match(tok)
111
+ or prev in _OPEN
112
+ or prev[-1:] in _OPEN
113
+ or (tok == '"' and quote_open)
114
+ or (prev == '"' and not quote_open)
115
+ or (tok in "-–—")
116
+ or (prev in "-–—")
117
+ or (tok in "'’" and quote_open)
118
+ )
119
+ out.append(tok if glue else " " + tok)
120
+ if tok == '"':
121
+ quote_open = not quote_open
122
+ return "".join(out)
123
+
124
+
125
+ # --------------------------------------------------------------------------
126
+ # alignment — token-level Levenshtein, then read off the edit tags
127
+ # --------------------------------------------------------------------------
128
+
129
+
130
+ def _case_transform(src: str, dst: str) -> str | None:
131
+ if src == dst or src.lower() != dst.lower():
132
+ return None
133
+ if dst == src.capitalize():
134
+ return CASE_CAPITAL
135
+ if dst == src.lower():
136
+ return CASE_LOWER
137
+ if dst == src.upper():
138
+ return CASE_UPPER
139
+ return None
140
+
141
+
142
+ def _flush_append(tags: list[str], src_pos: int, pending: list[str]) -> None:
143
+ if pending:
144
+ tags[src_pos - 1] = append_tag(" ".join(pending))
145
+ pending.clear()
146
+
147
+
148
+ def _tags_from_alignment(src: list[str], tgt: list[str]) -> list[str] | None:
149
+ n, m = len(src), len(tgt)
150
+ d = [[0] * (m + 1) for _ in range(n + 1)]
151
+ for i in range(1, n + 1):
152
+ d[i][0] = i
153
+ for j in range(1, m + 1):
154
+ d[0][j] = j
155
+ for i in range(1, n + 1):
156
+ for j in range(1, m + 1):
157
+ cost = 0 if src[i - 1] == tgt[j - 1] else 1
158
+ d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
159
+
160
+ tags: list[str] = [KEEP] * n
161
+ pending_append: list[str] = []
162
+ i, j = n, m
163
+ lead_insert: list[str] = []
164
+ while i > 0 or j > 0:
165
+ if i > 0 and j > 0 and src[i - 1] == tgt[j - 1] and d[i][j] == d[i - 1][j - 1]:
166
+ _flush_append(tags, i, pending_append)
167
+ i, j = i - 1, j - 1
168
+ elif i > 0 and j > 0 and d[i][j] == d[i - 1][j - 1] + 1:
169
+ ct = _case_transform(src[i - 1], tgt[j - 1])
170
+ if pending_append:
171
+ tags[i - 1] = replace_tag(" ".join([tgt[j - 1], *pending_append]))
172
+ pending_append.clear()
173
+ else:
174
+ tags[i - 1] = ct or replace_tag(tgt[j - 1])
175
+ i, j = i - 1, j - 1
176
+ elif j > 0 and d[i][j] == d[i][j - 1] + 1:
177
+ pending_append.insert(0, tgt[j - 1])
178
+ j -= 1
179
+ if i == 0:
180
+ lead_insert = list(pending_append)
181
+ else:
182
+ tags[i - 1] = DELETE
183
+ _flush_append(tags, i, pending_append)
184
+ i -= 1
185
+ if lead_insert:
186
+ return None
187
+ return tags
188
+
189
+
190
+ # --------------------------------------------------------------------------
191
+ # public: encode / apply / verify
192
+ # --------------------------------------------------------------------------
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class Tagged:
197
+ tokens: tuple[str, ...]
198
+ tags: tuple[str, ...]
199
+
200
+
201
+ def encode(corrupt: str, clean: str) -> Tagged | None:
202
+ """``(corrupt, clean)`` → token tags, or ``None`` if the scheme can't
203
+ represent the edit or the result won't round-trip."""
204
+ src, tgt = tokenize(corrupt), tokenize(clean)
205
+ if not src:
206
+ return None
207
+ tags = _tags_from_alignment(src, tgt)
208
+ if tags is None:
209
+ return None
210
+ tagged = Tagged(tuple(src), tuple(tags))
211
+ if apply(tagged.tokens, tagged.tags) != detokenize(tgt):
212
+ return None
213
+ return tagged
214
+
215
+
216
+ def apply(tokens: tuple[str, ...] | list[str],
217
+ tags: tuple[str, ...] | list[str]) -> str:
218
+ out: list[str] = []
219
+ for tok, tag in zip(tokens, tags, strict=True):
220
+ if tag == KEEP:
221
+ out.append(tok)
222
+ elif tag == DELETE:
223
+ continue
224
+ elif tag == CASE_CAPITAL:
225
+ out.append(tok.capitalize())
226
+ elif tag == CASE_LOWER:
227
+ out.append(tok.lower())
228
+ elif tag == CASE_UPPER:
229
+ out.append(tok.upper())
230
+ elif is_replace(tag):
231
+ out.extend(tokenize(tag_value(tag)))
232
+ elif is_append(tag):
233
+ out.append(tok)
234
+ out.extend(tokenize(tag_value(tag)))
235
+ else: # unknown tag → treat as KEEP (a miss, never a crash)
236
+ out.append(tok)
237
+ return detokenize(out)
238
+
239
+
240
+ def verify(corrupt: str, clean: str, tagged: Tagged) -> bool:
241
+ return apply(tagged.tokens, tagged.tags) == detokenize(tokenize(clean))