agentforge-framework 0.2.0__py3-none-any.whl

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 (89) hide show
  1. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,580 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Deterministic dimension-controlled pair generation for the teach calibration game.
4
+
5
+ Given a base passage and ONE voice dimension, produce a minimal pair (A, B) where
6
+ B differs from A along exactly that dimension. The direction of the transform
7
+ (e.g. contract -> expand vs expand -> contract) is auto-detected from whichever
8
+ pole the base passage already sits at, so every dimension is reversible: feed it
9
+ a passage already on one pole and it moves to the other.
10
+
11
+ Every transform must preserve the must-preserve constraint tokens extracted by
12
+ scripts/extract_constraints.py (numbers, dates, names, quotes, units, etc). If a
13
+ dimension has no expressible transform in the given passage (neither pole's
14
+ pattern is present, or the only candidate transform would drop a constraint),
15
+ the command exits 3 with "dimension not expressible in this passage".
16
+
17
+ Usage:
18
+ python3 calibrate_pairs.py generate --base FILE --dimension DIM --seed N
19
+ python3 calibrate_pairs.py --list-dimensions
20
+
21
+ Dimensions: contractions, em_dash, sentence_length, connectives, staccato.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import hashlib
27
+ import json
28
+ import random
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ HERE = Path(__file__).resolve().parent
34
+ sys.path.insert(0, str(HERE))
35
+
36
+ from extract_constraints import extract_constraints # noqa: E402
37
+ from banned_phrase_scan import scan_for_violations # noqa: E402
38
+
39
+ DIMENSIONS = ["contractions", "em_dash", "sentence_length", "connectives", "staccato"]
40
+
41
+ # Each dimension has two named poles. transform_applied is "<dimension>:<pole of B>".
42
+ POLES: dict[str, tuple[str, str]] = {
43
+ "contractions": ("contracted", "expanded"),
44
+ "em_dash": ("dashed", "plain"),
45
+ "sentence_length": ("long", "short"),
46
+ "connectives": ("plain", "formal"),
47
+ "staccato": ("staccato", "flowing"),
48
+ }
49
+
50
+
51
+ class NotExpressible(Exception):
52
+ """Raised when a dimension has no applicable transform in the passage."""
53
+
54
+
55
+ # --------------------------------------------------------------------------
56
+ # contractions: expand <-> contract via a fixed, unambiguous mapping table.
57
+ # --------------------------------------------------------------------------
58
+
59
+ _CONTRACTION_PAIRS = [
60
+ ("do not", "don't"), ("does not", "doesn't"), ("did not", "didn't"),
61
+ ("cannot", "can't"), ("will not", "won't"), ("would not", "wouldn't"),
62
+ ("should not", "shouldn't"), ("could not", "couldn't"), ("must not", "mustn't"),
63
+ ("is not", "isn't"), ("are not", "aren't"), ("was not", "wasn't"), ("were not", "weren't"),
64
+ ("have not", "haven't"), ("has not", "hasn't"), ("had not", "hadn't"),
65
+ ("I am", "I'm"), ("you are", "you're"), ("we are", "we're"), ("they are", "they're"),
66
+ ("I will", "I'll"), ("you will", "you'll"), ("we will", "we'll"), ("they will", "they'll"),
67
+ ("he will", "he'll"), ("she will", "she'll"), ("it will", "it'll"),
68
+ ("I have", "I've"), ("you have", "you've"), ("we have", "we've"), ("they have", "they've"),
69
+ ("let us", "let's"),
70
+ ("it is", "it's"), ("that is", "that's"), ("there is", "there's"),
71
+ ("here is", "here's"), ("who is", "who's"), ("what is", "what's"),
72
+ ]
73
+ # Pairs whose expanded side always keeps a hard-capitalized pronoun ("I ...").
74
+ _HARD_CAPITAL = {e for e, c in _CONTRACTION_PAIRS if e.startswith("I ")}
75
+
76
+
77
+ def _case_like(template: str, source_first_char: str) -> str:
78
+ if source_first_char.isupper():
79
+ return template[0].upper() + template[1:]
80
+ return template[0].lower() + template[1:]
81
+
82
+
83
+ # Words safe to lowercase when a sentence is folded mid-clause. A capitalized
84
+ # word NOT in this set is assumed to be a proper noun (constraint) and is left
85
+ # alone -- lowercasing "Priya" into a joined clause would corrupt a name.
86
+ _LOWERABLE_JOINERS = {
87
+ "the", "it", "she", "he", "they", "this", "that", "there", "i", "we", "you",
88
+ "who", "what", "when", "where", "why", "how", "a", "an", "and", "but", "so",
89
+ "yet", "or", "nobody", "everybody", "everyone", "someone", "something",
90
+ "nothing", "no", "her", "his", "its", "their", "our", "your",
91
+ }
92
+
93
+
94
+ def _lower_first_word(s: str) -> str:
95
+ """Lowercase the leading word only if it is a common function word/pronoun,
96
+ never a token that looks like a proper noun a constraint might depend on."""
97
+ m = re.match(r"[A-Za-z']+", s)
98
+ if not m:
99
+ return s
100
+ word = m.group(0)
101
+ if word.lower() in _LOWERABLE_JOINERS:
102
+ return word.lower() + s[len(word):]
103
+ return s
104
+
105
+
106
+ _WORD_BEFORE_RE = re.compile(r"([A-Za-z']+)\s*$")
107
+ _WORD_AFTER_RE = re.compile(r"^\s*([A-Za-z']+)")
108
+
109
+
110
+ def _is_title_case(word: str) -> bool:
111
+ return bool(word) and word[0].isupper() and word[1:].islower()
112
+
113
+
114
+ def _in_capitalized_span(m: "re.Match[str]") -> bool:
115
+ """True if this contraction/expansion match sits inside a capitalized
116
+ multi-word span (e.g. "Venue Can't Stop") that looks like a proper noun,
117
+ rather than an ordinary sentence-level contraction.
118
+
119
+ A contraction rewrite is only skipped when the match ITSELF is
120
+ capitalized (so an ordinary sentence-initial "Can't you..." is untouched)
121
+ AND an immediately adjacent word is also Title Case, forming a >= 2-word
122
+ capitalized run. "Maria Chen can't attend" is not skipped (the match is
123
+ lowercase); "the Venue Can't Stop" is skipped (both "Venue" and "Can't"
124
+ are capitalized and adjacent).
125
+ """
126
+ matched = m.group(0)
127
+ if not matched[0].isupper():
128
+ return False
129
+ text = m.string
130
+ before_m = _WORD_BEFORE_RE.search(text[: m.start()])
131
+ after_m = _WORD_AFTER_RE.match(text[m.end() :])
132
+ before_word = before_m.group(1) if before_m else ""
133
+ after_word = after_m.group(1) if after_m else ""
134
+ return _is_title_case(before_word) or _is_title_case(after_word)
135
+
136
+
137
+ def _contraction_repl(replacement: str, hard: bool):
138
+ """One replacement closure shared by both contraction directions (expand
139
+ and contract) and both casing modes (hard-capital literal vs case-matched).
140
+ """
141
+ def repl(m):
142
+ if _in_capitalized_span(m):
143
+ return m.group(0)
144
+ return replacement if hard else _case_like(replacement, m.group(0)[0])
145
+ return repl
146
+
147
+
148
+ def _apply_contractions(text: str) -> tuple[str, str]:
149
+ contract_hits = []
150
+ expand_hits = []
151
+ for expanded, contracted in _CONTRACTION_PAIRS:
152
+ if expanded in _HARD_CAPITAL:
153
+ # "I am"/"I'll"/... always literally capitalized; case-sensitive match.
154
+ if re.search(re.escape(contracted), text):
155
+ contract_hits.append((expanded, contracted))
156
+ if re.search(re.escape(expanded), text):
157
+ expand_hits.append((expanded, contracted))
158
+ else:
159
+ if re.search(r"\b" + re.escape(contracted) + r"\b", text, re.IGNORECASE):
160
+ contract_hits.append((expanded, contracted))
161
+ if re.search(r"\b" + re.escape(expanded) + r"\b", text, re.IGNORECASE):
162
+ expand_hits.append((expanded, contracted))
163
+
164
+ if contract_hits:
165
+ out = text
166
+ for expanded, contracted in contract_hits:
167
+ hard = expanded in _HARD_CAPITAL
168
+ pattern = re.escape(contracted) if hard else r"\b" + re.escape(contracted) + r"\b"
169
+ flags = 0 if hard else re.IGNORECASE
170
+ out = re.sub(pattern, _contraction_repl(expanded, hard), out, flags=flags)
171
+ return out, "expanded"
172
+
173
+ if expand_hits:
174
+ out = text
175
+ for expanded, contracted in expand_hits:
176
+ hard = expanded in _HARD_CAPITAL
177
+ pattern = re.escape(expanded) if hard else r"\b" + re.escape(expanded) + r"\b"
178
+ flags = 0 if hard else re.IGNORECASE
179
+ out = re.sub(pattern, _contraction_repl(contracted, hard), out, flags=flags)
180
+ return out, "contracted"
181
+
182
+ raise NotExpressible("no contraction or expandable phrase found")
183
+
184
+
185
+ # --------------------------------------------------------------------------
186
+ # em_dash: paired em-dash parentheticals <-> paired-comma parentheticals ONLY.
187
+ #
188
+ # Only the paired-dash<->paired-comma path is expressible for this dimension.
189
+ # A lone joiner dash (" — nobody objected") has no comma-pair equivalent that
190
+ # preserves sentence count: turning it into a period (the old behavior) split
191
+ # one sentence into two, silently changing sentence count between A and B.
192
+ # Passages with only a lone dash (no paired construction) are declined rather
193
+ # than forced through a transform that shifts sentence count.
194
+ # --------------------------------------------------------------------------
195
+
196
+ _PAIRED_DASH_RE = re.compile(r"\s—\s(.+?)\s—\s")
197
+ _PAIRED_COMMA_RE = re.compile(r",\s+(.+?),\s+")
198
+
199
+
200
+ def _apply_em_dash(text: str) -> tuple[str, str]:
201
+ if _PAIRED_DASH_RE.search(text):
202
+ out = _PAIRED_DASH_RE.sub(lambda m: ", " + m.group(1) + ", ", text)
203
+ if "—" in out:
204
+ # A lone dash survived alongside the paired one -- the restricted
205
+ # paired-dash<->comma path can't fully express this passage
206
+ # without also touching the lone dash (and changing sentence
207
+ # count), so decline rather than ship a half-converted pair.
208
+ raise NotExpressible(
209
+ "passage mixes a paired em dash with a lone em dash; "
210
+ "the paired-dash<->comma path can't resolve the lone dash "
211
+ "without changing sentence count"
212
+ )
213
+ return out, "plain"
214
+
215
+ if "—" in text:
216
+ raise NotExpressible(
217
+ "only a lone em dash found; the em_dash dimension is restricted "
218
+ "to the paired-dash<->comma path so sentence count stays stable"
219
+ )
220
+
221
+ if _PAIRED_COMMA_RE.search(text):
222
+ out = _PAIRED_COMMA_RE.sub(lambda m: " — " + m.group(1) + " — ", text)
223
+ return out, "dashed"
224
+
225
+ raise NotExpressible("no paired em dash or comma-bounded parenthetical found")
226
+
227
+
228
+ # --------------------------------------------------------------------------
229
+ # sentence_length: split at coordinators <-> join short adjacent sentences.
230
+ # --------------------------------------------------------------------------
231
+
232
+ _COORD_RE = re.compile(r",\s+(and|but|or|so|yet)\s+", re.IGNORECASE)
233
+
234
+
235
+ def _apply_sentence_length(text: str) -> tuple[str, str]:
236
+ sentences = re.findall(r"[^.!?]+[.!?]+", text)
237
+
238
+ for sent in sentences:
239
+ for m in _COORD_RE.finditer(sent):
240
+ before = sent[:m.start()]
241
+ after = sent[m.end():]
242
+ if len(before.split()) >= 3 and len(after.split()) >= 3:
243
+ new_sent = before.rstrip() + ". " + m.group(1).capitalize() + " " + after
244
+ out = text.replace(sent, new_sent, 1)
245
+ return re.sub(r"\s+", " ", out).strip(), "short"
246
+
247
+ for i in range(len(sentences) - 1):
248
+ a_words = sentences[i].strip().split()
249
+ b_words = sentences[i + 1].strip().split()
250
+ if len(a_words) <= 8 and len(b_words) <= 8:
251
+ first_no_period = re.sub(r"[.!?]+\s*$", "", sentences[i].strip())
252
+ second = sentences[i + 1].strip()
253
+ second_body = re.sub(r"[.!?]+\s*$", "", second)
254
+ end_punct = second[len(second_body):].strip() or "."
255
+ joined = first_no_period + ", and " + _lower_first_word(second_body) + end_punct
256
+ prefix = "".join(sentences[:i])
257
+ suffix = "".join(sentences[i + 2:])
258
+ out = (prefix + " " + joined + " " + suffix).strip()
259
+ return re.sub(r"\s+", " ", out), "long"
260
+
261
+ raise NotExpressible("no coordinator split site or joinable short sentences found")
262
+
263
+
264
+ # --------------------------------------------------------------------------
265
+ # connectives: formal <-> plain via a fixed table.
266
+ # --------------------------------------------------------------------------
267
+
268
+ _FORMAL_TO_PLAIN = {
269
+ "however": "But", "additionally": "Also", "therefore": "So",
270
+ "furthermore": "Also", "moreover": "Also", "nevertheless": "Still",
271
+ "consequently": "So", "nonetheless": "Still", "subsequently": "Then",
272
+ "thus": "So",
273
+ }
274
+ _PLAIN_TO_FORMAL = {
275
+ "but": "However", "also": "Additionally", "so": "Therefore",
276
+ "still": "Nevertheless", "then": "Subsequently",
277
+ }
278
+ _FORMAL_RE = re.compile(
279
+ r"(?:^|(?<=[.!?]\s))(" + "|".join(_FORMAL_TO_PLAIN) + r"),?\s+", re.IGNORECASE
280
+ )
281
+ _PLAIN_RE = re.compile(
282
+ r"(?:^|(?<=[.!?]\s))(" + "|".join(_PLAIN_TO_FORMAL) + r"),?\s+", re.IGNORECASE
283
+ )
284
+
285
+
286
+ def _apply_connectives(text: str) -> tuple[str, str]:
287
+ if _FORMAL_RE.search(text):
288
+ def repl(m):
289
+ # No comma after the plain form: "But " reads as ordinary speech;
290
+ # "But, " is stilted, and "So, " specifically trips the scanner's
291
+ # filler_opener structural pattern. Dropping the comma across the
292
+ # board keeps every plain form clean of that tell.
293
+ return _FORMAL_TO_PLAIN[m.group(1).lower()] + " "
294
+ out = _FORMAL_RE.sub(repl, text)
295
+ return out, "plain"
296
+
297
+ if _PLAIN_RE.search(text):
298
+ def repl2(m):
299
+ return _PLAIN_TO_FORMAL[m.group(1).lower()] + ", "
300
+ out = _PLAIN_RE.sub(repl2, text)
301
+ return out, "formal"
302
+
303
+ raise NotExpressible("no formal or plain connective found")
304
+
305
+
306
+ # --------------------------------------------------------------------------
307
+ # staccato: fragment runs <-> flowing clauses.
308
+ # --------------------------------------------------------------------------
309
+
310
+ _LEADING_JOINER_RE = re.compile(
311
+ r"^(?:because|although|while|since|if|when|and|but|so|yet|or)\s+", re.IGNORECASE
312
+ )
313
+
314
+
315
+ def _apply_staccato(text: str, rng: random.Random) -> tuple[str, str]:
316
+ sentences = re.findall(r"[^.!?]+[.!?]+", text)
317
+
318
+ # Reverse direction: a run of >= 3 consecutive short (<=6 word) sentences -> flow.
319
+ runs = []
320
+ run_start = None
321
+ for i, sent in enumerate(sentences):
322
+ words = sent.strip().split()
323
+ if len(words) <= 6:
324
+ if run_start is None:
325
+ run_start = i
326
+ else:
327
+ if run_start is not None and i - run_start >= 3:
328
+ runs.append((run_start, i))
329
+ run_start = None
330
+ if run_start is not None and len(sentences) - run_start >= 3:
331
+ runs.append((run_start, len(sentences)))
332
+
333
+ if runs:
334
+ start, end = runs[rng.randrange(len(runs))] if len(runs) > 1 else runs[0]
335
+ parts = []
336
+ for idx in range(start, end):
337
+ body = re.sub(r"[.!?]+\s*$", "", sentences[idx].strip())
338
+ parts.append(body)
339
+ joined_parts = []
340
+ for i, p in enumerate(parts):
341
+ if i == 0:
342
+ joined_parts.append(p)
343
+ elif i == len(parts) - 1:
344
+ joined_parts.append("and " + _lower_first_word(p))
345
+ else:
346
+ joined_parts.append(_lower_first_word(p))
347
+ joined = ", ".join(joined_parts) + "."
348
+ prefix = "".join(sentences[:start])
349
+ suffix = "".join(sentences[end:])
350
+ out = (prefix + " " + joined + " " + suffix).strip()
351
+ return re.sub(r"\s+", " ", out), "flowing"
352
+
353
+ # Forward direction: the sentence with the most commas (>= 2) fragments.
354
+ best_idx, best_commas = None, 1
355
+ for i, sent in enumerate(sentences):
356
+ commas = sent.count(",")
357
+ if commas > best_commas:
358
+ best_idx, best_commas = i, commas
359
+
360
+ if best_idx is not None:
361
+ sent = sentences[best_idx]
362
+ body = re.sub(r"[.!?]+\s*$", "", sent.strip())
363
+ end_punct = sent.strip()[len(body):].strip() or "."
364
+ fragments = [f.strip() for f in body.split(",")]
365
+ cleaned = []
366
+ for frag in fragments:
367
+ frag = _LEADING_JOINER_RE.sub("", frag).strip()
368
+ if not frag:
369
+ continue
370
+ frag = frag[0].upper() + frag[1:]
371
+ cleaned.append(frag)
372
+ if len(cleaned) < 3:
373
+ raise NotExpressible("comma split did not yield a fragment run")
374
+ cleaned[-1] = cleaned[-1] + end_punct if not cleaned[-1].endswith((".", "!", "?")) else cleaned[-1]
375
+ new_sent = " ".join(
376
+ f if f.endswith((".", "!", "?")) else f + "." for f in cleaned
377
+ )
378
+ out = text.replace(sent, new_sent, 1)
379
+ return re.sub(r"\s+", " ", out).strip(), "staccato"
380
+
381
+ raise NotExpressible("no fragment run or multi-comma sentence found")
382
+
383
+
384
+ _APPLY = {
385
+ "contractions": lambda text, rng: _apply_contractions(text),
386
+ "em_dash": lambda text, rng: _apply_em_dash(text),
387
+ "sentence_length": lambda text, rng: _apply_sentence_length(text),
388
+ "connectives": lambda text, rng: _apply_connectives(text),
389
+ "staccato": _apply_staccato,
390
+ }
391
+
392
+ _EXAMPLES = {
393
+ "contractions": {
394
+ "description": "Expand <-> contract via a fixed, unambiguous mapping table "
395
+ "(do not <-> don't, I am <-> I'm, it is <-> it's, ...).",
396
+ "a": "The rollout is not finished, and I am not confident it will ship Friday.",
397
+ "b": "The rollout isn't finished, and I'm not confident it'll ship Friday.",
398
+ },
399
+ "em_dash": {
400
+ "description": "Paired em-dash parentheticals <-> paired-comma parentheticals only "
401
+ "(a lone joiner dash has no comma-pair equivalent that preserves "
402
+ "sentence count, so it is declined rather than converted).",
403
+ "a": "The plan — untested and rushed — still shipped on time.",
404
+ "b": "The plan, untested and rushed, still shipped on time.",
405
+ },
406
+ "sentence_length": {
407
+ "description": "Split at a coordinator <-> join two short adjacent sentences.",
408
+ "a": "The team shipped the fix, and the client renewed the contract.",
409
+ "b": "The team shipped the fix. And the client renewed the contract.",
410
+ },
411
+ "connectives": {
412
+ "description": "Formal <-> plain connective swap via a fixed table "
413
+ "(However -> But, Additionally -> Also, Therefore -> So, ...). "
414
+ "Plain forms drop the comma after the connective (\"But \", not "
415
+ "\"But, \") so they read as ordinary speech, not a filler-opener tell.",
416
+ "a": "However, the numbers slipped in March.",
417
+ "b": "But the numbers slipped in March.",
418
+ },
419
+ "staccato": {
420
+ "description": "Fragment runs <-> flowing clauses.",
421
+ "a": "Because the deploy failed, and the on-call missed the page, the team lost an hour.",
422
+ "b": "The deploy failed. The on-call missed the page. The team lost an hour.",
423
+ },
424
+ }
425
+
426
+
427
+ def _is_word_char(ch: str) -> bool:
428
+ return ch.isalnum()
429
+
430
+
431
+ def _has_whole_occurrence(value: str, text: str) -> bool:
432
+ """True if `value` occurs in `text` as a standalone span rather than as a
433
+ substring straddling the edge of a longer, different word.
434
+
435
+ Plain substring containment is not enough: expanding "Can't" to "Cannot"
436
+ still contains the literal characters "Can", so a naive `"Can" in
437
+ "Cannot"` check reports a corrupted proper noun as "preserved". At each
438
+ edge of a candidate match, this only counts as a clash (disqualifying the
439
+ match) when BOTH the value's own boundary character and the adjacent text
440
+ character are alphanumeric -- i.e. when `value` could be glued onto a
441
+ longer token. Values that start/end with punctuation (currency signs,
442
+ quotes) never clash on that edge, since punctuation can't be silently
443
+ swallowed into a bigger word the way a letter/digit can.
444
+ """
445
+ if not value:
446
+ return False
447
+ start = 0
448
+ while True:
449
+ idx = text.find(value, start)
450
+ if idx == -1:
451
+ return False
452
+ left_clash = idx > 0 and _is_word_char(text[idx - 1]) and _is_word_char(value[0])
453
+ end = idx + len(value)
454
+ right_clash = end < len(text) and _is_word_char(text[end]) and _is_word_char(value[-1])
455
+ if not left_clash and not right_clash:
456
+ return True
457
+ start = idx + 1
458
+
459
+
460
+ def _verify_constraints_preserved(base_text: str, transformed_text: str) -> list[str]:
461
+ """Return constraint values from base_text missing from transformed_text.
462
+
463
+ Uses whole-occurrence (token-boundary) comparison rather than plain
464
+ substring containment -- see `_has_whole_occurrence` for why a substring
465
+ check is unsafe here.
466
+ """
467
+ missing = []
468
+ for c in extract_constraints(base_text):
469
+ value = c["value"]
470
+ if _has_whole_occurrence(value, transformed_text):
471
+ continue
472
+ # Allow whitespace-normalized matches (transforms may collapse spacing).
473
+ normalized_value = re.sub(r"\s+", " ", value)
474
+ normalized_text = re.sub(r"\s+", " ", transformed_text)
475
+ if normalized_value != value and _has_whole_occurrence(normalized_value, normalized_text):
476
+ continue
477
+ missing.append(value)
478
+ return missing
479
+
480
+
481
+ def _scan_flags(text: str) -> list[str]:
482
+ """Category names banned_phrase_scan raises on `text`, deduped and sorted.
483
+
484
+ Empty when the text scans clean. Generated B-variants can legitimately
485
+ trip the scanner (e.g. a staccato pole reads as anti_slop_register; see
486
+ references/calibrate.md "Voice overrides defaults") -- that is surfaced
487
+ here, not treated as a reason to decline the pair.
488
+ """
489
+ categories = {v["category"] for v in scan_for_violations(text)}
490
+ return sorted(categories)
491
+
492
+
493
+ def generate_pair(base_text: str, dimension: str, seed: int) -> dict:
494
+ if dimension not in DIMENSIONS:
495
+ raise ValueError(f"unknown dimension: {dimension}")
496
+
497
+ rng = random.Random(seed)
498
+ transformed, pole = _APPLY[dimension](base_text, rng)
499
+
500
+ if transformed.strip() == base_text.strip():
501
+ raise NotExpressible("transform produced no change")
502
+
503
+ missing = _verify_constraints_preserved(base_text, transformed)
504
+ if missing:
505
+ raise NotExpressible(
506
+ "transform would drop must-preserve constraint(s): " + ", ".join(missing)
507
+ )
508
+
509
+ pair_id = hashlib.sha256(
510
+ f"{base_text}|{dimension}|{seed}".encode("utf-8")
511
+ ).hexdigest()
512
+
513
+ return {
514
+ "pair_id": pair_id,
515
+ "dimension": dimension,
516
+ "a_text": base_text,
517
+ "b_text": transformed,
518
+ "transform_applied": f"{dimension}:{pole}",
519
+ "a_flags": _scan_flags(base_text),
520
+ "b_flags": _scan_flags(transformed),
521
+ }
522
+
523
+
524
+ def list_dimensions() -> dict:
525
+ return {
526
+ dim: {
527
+ "poles": list(POLES[dim]),
528
+ "description": _EXAMPLES[dim]["description"],
529
+ "example": {"a": _EXAMPLES[dim]["a"], "b": _EXAMPLES[dim]["b"]},
530
+ }
531
+ for dim in DIMENSIONS
532
+ }
533
+
534
+
535
+ def parse_args(argv: list[str]) -> argparse.Namespace:
536
+ parser = argparse.ArgumentParser(description=__doc__)
537
+ sub = parser.add_subparsers(dest="command")
538
+
539
+ gen = sub.add_parser("generate", help="generate one dimension-controlled pair")
540
+ gen.add_argument("--base", required=True, help="path to base passage file")
541
+ gen.add_argument("--dimension", required=True, choices=DIMENSIONS)
542
+ gen.add_argument("--seed", type=int, default=0)
543
+
544
+ parser.add_argument("--list-dimensions", action="store_true")
545
+ return parser.parse_args(argv)
546
+
547
+
548
+ def main(argv: list[str]) -> int:
549
+ args = parse_args(argv)
550
+
551
+ if args.list_dimensions:
552
+ print(json.dumps(list_dimensions(), indent=2, sort_keys=True))
553
+ return 0
554
+
555
+ if args.command != "generate":
556
+ print(json.dumps({"error": "no command given; use generate or --list-dimensions"}))
557
+ return 1
558
+
559
+ try:
560
+ base_text = Path(args.base).read_text()
561
+ except OSError as e:
562
+ print(json.dumps({"error": f"could not read base file: {e}"}))
563
+ return 2
564
+
565
+ try:
566
+ pair = generate_pair(base_text, args.dimension, args.seed)
567
+ except NotExpressible as e:
568
+ print(json.dumps({
569
+ "error": "dimension not expressible in this passage",
570
+ "dimension": args.dimension,
571
+ "detail": str(e),
572
+ }))
573
+ return 3
574
+
575
+ print(json.dumps(pair, indent=2, sort_keys=True))
576
+ return 0
577
+
578
+
579
+ if __name__ == "__main__":
580
+ sys.exit(main(sys.argv[1:]))