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,496 @@
1
+ #!/usr/bin/env python3
2
+ """Distill a voice profile + samples into a LAYERED, pack-sized style card.
3
+
4
+ Output layout (deterministic — same inputs give byte-identical files):
5
+
6
+ <out>/card.md core sheet, always loaded, <= 300 words, with an
7
+ index table "when writing X, read card/X.md"
8
+ <out>/card/<situation>.md one sheet per COVERED situation from the taxonomy
9
+
10
+ Only situations with real sample evidence get a sheet. Uncovered situations are
11
+ NAMED in card.md under "Uncovered" and never given a fabricated sheet — every
12
+ claim on every sheet is derived from a measurable profile/sample fact or a
13
+ verbatim sample snippet.
14
+
15
+ ``--coverage`` emits the deterministic lexical coverage matrix (which taxonomy
16
+ dimensions the samples exercise) as JSON and writes nothing. The classifier is
17
+ intentionally coarse: it only DRIVES interactive teach prompts and the sheet
18
+ set. A misclassified sentence can add or drop a sheet, never a card claim, so
19
+ misclassification is low-stakes by construction.
20
+
21
+ ``--provenance`` also writes <out>/provenance.json (per-sample sha256 + word
22
+ counts, doc count, genre note, low-confidence flag) so a teach run is auditable.
23
+ """
24
+
25
+ import argparse
26
+ import hashlib
27
+ import json
28
+ import re
29
+ import statistics
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ import voice_profile
34
+
35
+ # The situation taxonomy. openings/closings are STRUCTURAL (first/last sentence
36
+ # of every document); the rest are lexical. Order is fixed for determinism.
37
+ TAXONOMY = [
38
+ "explaining-technical",
39
+ "anecdote",
40
+ "argument",
41
+ "disagreement",
42
+ "praise",
43
+ "hedging-uncertainty",
44
+ "numbers-data",
45
+ "addressing-reader",
46
+ "openings",
47
+ "closings",
48
+ ]
49
+
50
+ STRUCTURAL = {"openings", "closings"}
51
+
52
+ # Minimum classified sentences before a lexical dimension counts as covered.
53
+ COVER_THRESHOLD = 2
54
+
55
+ NUMBER_WORDS = {
56
+ "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
57
+ "ten", "eleven", "twelve", "dozen", "hundred", "thousand", "million",
58
+ "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty",
59
+ "ninety", "percent", "half", "quarter", "double", "triple", "nine",
60
+ }
61
+
62
+ # Lexical signals per dimension. Each entry is a set of lowercase substrings;
63
+ # a sentence matches the dimension if any signal is present (word-boundary
64
+ # aware for single tokens, plain substring for multiword phrases).
65
+ SIGNALS = {
66
+ "explaining-technical": {
67
+ "because", "so that", "which means", "the reason", "works by",
68
+ "depends on", "the way it", "in order to", "that's how", "this is how",
69
+ "the trick is", "you have to", "the point of",
70
+ },
71
+ "argument": {
72
+ "therefore", "thus", "consequently", "the point is", "i distrust",
73
+ "clearly", "obviously", "in fact", "the truth is", "matters because",
74
+ "that's the point", "either way", "that's rare", "nobody", "no one",
75
+ },
76
+ "disagreement": {
77
+ "but i", "i don't", "no one", "nobody", "wrong", "i distrust",
78
+ "rather than", "not because", "i hate", "don't trust", "disagree",
79
+ "however", "i wasn't", "makes sense", "i can't",
80
+ },
81
+ "praise": {
82
+ "extraordinary", "wonderful", "dependable", "lovely", "beautiful",
83
+ "kindly", "generous", "grateful", "delightful", "plenty", "good choice",
84
+ "with care", "extraordinary care", "almost pleasant", "i like",
85
+ },
86
+ "hedging-uncertainty": {
87
+ "maybe", "perhaps", "probably", "might", "i guess", "i think",
88
+ "seems", "sort of", "kind of", "possibly", "i suppose", "not sure",
89
+ "or won't", "or it won't", "i believed", "or maybe",
90
+ },
91
+ "addressing-reader": set(), # handled specially (2nd person / question)
92
+ "numbers-data": set(), # handled specially (digits / number words)
93
+ "anecdote": set(), # handled specially (1st person + past/time cue)
94
+ }
95
+
96
+ FIRST_PERSON = {"i", "we", "my", "me", "we've", "i've", "i'll", "i'd", "our"}
97
+ TIME_CUES = {
98
+ "today", "yesterday", "tonight", "morning", "evening", "night", "ago",
99
+ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
100
+ "last", "once", "then", "later", "week", "year",
101
+ }
102
+
103
+
104
+ def sha256_file(path):
105
+ return hashlib.sha256(path.read_bytes()).hexdigest()
106
+
107
+
108
+ def split_sentences_text(text):
109
+ """Split into trimmed sentence strings, preserving original wording."""
110
+ out = []
111
+ for chunk in re.split(r"(?<=[.!?])\s+", text.strip()):
112
+ chunk = re.sub(r"\s+", " ", chunk).strip()
113
+ if chunk:
114
+ out.append(chunk)
115
+ return out
116
+
117
+
118
+ def _has_word(sentence_low, token):
119
+ return re.search(r"\b" + re.escape(token) + r"\b", sentence_low) is not None
120
+
121
+
122
+ def _matches(sentence_low, signals):
123
+ for sig in signals:
124
+ if " " in sig:
125
+ if sig in sentence_low:
126
+ return True
127
+ elif _has_word(sentence_low, sig):
128
+ return True
129
+ return False
130
+
131
+
132
+ def _is_numbers(sentence_low):
133
+ if re.search(r"\d", sentence_low):
134
+ return True
135
+ return any(_has_word(sentence_low, w) for w in NUMBER_WORDS)
136
+
137
+
138
+ def _is_anecdote(sentence_low):
139
+ toks = set(voice_profile.words(sentence_low))
140
+ if not (toks & FIRST_PERSON):
141
+ return False
142
+ if toks & TIME_CUES:
143
+ return True
144
+ return any(t.endswith("ed") and len(t) > 3 for t in toks)
145
+
146
+
147
+ def _is_addressing(sentence):
148
+ low = sentence.lower()
149
+ if sentence.rstrip().endswith("?"):
150
+ return True
151
+ return _has_word(low, "you") or _has_word(low, "your") or _has_word(low, "you're")
152
+
153
+
154
+ def classify_dimension(sentence):
155
+ """Return the set of lexical dimensions a single sentence exercises."""
156
+ low = sentence.lower()
157
+ hit = set()
158
+ for dim, signals in SIGNALS.items():
159
+ if dim in ("addressing-reader", "numbers-data", "anecdote"):
160
+ continue
161
+ if _matches(low, signals):
162
+ hit.add(dim)
163
+ if _is_numbers(low):
164
+ hit.add("numbers-data")
165
+ if _is_addressing(sentence):
166
+ hit.add("addressing-reader")
167
+ if _is_anecdote(low):
168
+ hit.add("anecdote")
169
+ return hit
170
+
171
+
172
+ def collect(samples_dir):
173
+ """Return (docs, sentences) where each doc is a list of sentence strings."""
174
+ docs = []
175
+ for path in voice_profile.iter_docs(samples_dir):
176
+ sents = split_sentences_text(path.read_text(errors="replace"))
177
+ if sents:
178
+ docs.append(sents)
179
+ return docs
180
+
181
+
182
+ def coverage_matrix(docs):
183
+ """Deterministic coverage of the taxonomy over the sample sentences."""
184
+ buckets = {dim: [] for dim in TAXONOMY}
185
+ for doc in docs:
186
+ if doc:
187
+ buckets["openings"].append(doc[0])
188
+ buckets["closings"].append(doc[-1])
189
+ for sent in doc:
190
+ for dim in classify_dimension(sent):
191
+ buckets[dim].append(sent)
192
+ matrix = {}
193
+ for dim in TAXONOMY:
194
+ sents = buckets[dim]
195
+ if dim in STRUCTURAL:
196
+ covered = len(docs) >= 1
197
+ else:
198
+ covered = len(sents) >= COVER_THRESHOLD
199
+ matrix[dim] = {
200
+ "count": len(sents),
201
+ "covered": covered,
202
+ "structural": dim in STRUCTURAL,
203
+ }
204
+ return matrix, buckets
205
+
206
+
207
+ def _shortest(sentences, k=3):
208
+ ordered = sorted(set(sentences), key=lambda s: (len(s), s))
209
+ return ordered[:k]
210
+
211
+
212
+ def _contraction_examples(docs, k=3):
213
+ seen = {}
214
+ for doc in docs:
215
+ for sent in doc:
216
+ for tok in voice_profile.words(sent):
217
+ if "'" in tok:
218
+ seen[tok] = seen.get(tok, 0) + 1
219
+ ranked = sorted(seen.items(), key=lambda kv: (-kv[1], kv[0]))
220
+ return [w for w, _ in ranked[:k]]
221
+
222
+
223
+ def _opener_words(docs, k=5):
224
+ counts = {}
225
+ for doc in docs:
226
+ for sent in doc:
227
+ toks = voice_profile.words(sent)
228
+ if toks:
229
+ counts[toks[0]] = counts.get(toks[0], 0) + 1
230
+ ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
231
+ return [w for w, _ in ranked[:k]]
232
+
233
+
234
+ def _burstiness(profile):
235
+ med = profile["sentence_lengths"]["median"]
236
+ iqr = profile["sentence_lengths"]["iqr"]
237
+ if med <= 0:
238
+ return "uneven"
239
+ ratio = iqr / med
240
+ if ratio < 0.5:
241
+ return "steady"
242
+ if ratio < 1.1:
243
+ return "moderately bursty"
244
+ return "very bursty"
245
+
246
+
247
+ def _numbers_tokens(sentences):
248
+ toks = set()
249
+ for sent in sentences:
250
+ for m in re.findall(r"\d[\d,:.]*", sent):
251
+ toks.add(m)
252
+ for w in voice_profile.words(sent.lower()):
253
+ if w in NUMBER_WORDS:
254
+ toks.add(w)
255
+ return sorted(toks)
256
+
257
+
258
+ def _sheet_markers(dim, sentences, profile):
259
+ lengths = [len(voice_profile.words(s)) for s in sentences]
260
+ med = statistics.median(lengths) if lengths else 0
261
+ lines = [f"- Sentences in samples exercising this: {len(sentences)}.",
262
+ f"- Median length of those sentences: {int(med)} words."]
263
+ if dim == "numbers-data":
264
+ toks = _numbers_tokens(sentences)
265
+ lines.append("- Numeric tokens actually used: " + ", ".join(toks[:12]) + ".")
266
+ elif dim == "addressing-reader":
267
+ q = sum(1 for s in sentences if s.rstrip().endswith("?"))
268
+ lines.append(f"- Direct questions to the reader: {q}.")
269
+ lines.append("- Second person appears; keep it plain, no salesy 'you'.")
270
+ elif dim == "openings":
271
+ openers = sorted({voice_profile.words(s)[0] for s in sentences if voice_profile.words(s)})
272
+ lines.append("- Documents open on: " + ", ".join(openers[:8]) + ".")
273
+ elif dim == "closings":
274
+ lines.append("- Endings land on a concrete image, not a moral recap.")
275
+ else:
276
+ cr = profile["contraction_rate"]
277
+ lines.append(f"- Overall contraction rate: {cr:.3f} (keep it consistent here).")
278
+ return lines
279
+
280
+
281
+ SHEET_HOWTO = {
282
+ "explaining-technical": "Explain by naming the concrete mechanism, not the abstraction. Short causal sentences; 'because' does the work.",
283
+ "anecdote": "Tell it first person, past tense, one scene at a time. Concrete nouns, sensory detail, no summarizing moral.",
284
+ "argument": "State the claim flat, then the reason. No hedging scaffold; the point lands in one line.",
285
+ "disagreement": "Disagree by contrast, not confrontation. 'I don't', 'rather than', a plain preference rather than a takedown.",
286
+ "praise": "Praise through specific, restrained detail. Understated approval, never gushing.",
287
+ "hedging-uncertainty": "Hold uncertainty lightly with 'maybe' / 'probably' / 'or it won't', not corporate qualifiers.",
288
+ "numbers-data": "Numbers stay small, concrete, woven into the scene rather than tabulated.",
289
+ "addressing-reader": "Address the reader sparingly and plainly; a direct question or a flat 'you can'.",
290
+ "openings": "Open cold on a concrete fact or action. No throat-clearing, no thesis statement.",
291
+ "closings": "Close on a small, specific image. No wrap-up, no 'ultimately'.",
292
+ }
293
+
294
+
295
+ def build_sheet(dim, sentences, profile):
296
+ title = dim.replace("-", " ")
297
+ lines = [f"# Voice sheet: {title}", ""]
298
+ lines.append(SHEET_HOWTO[dim])
299
+ lines.append("")
300
+ lines.append("## Sample snippets")
301
+ for snip in _shortest(sentences):
302
+ lines.append(f"> {snip}")
303
+ lines.append("")
304
+ lines.append("## Measured markers")
305
+ lines.extend(_sheet_markers(dim, sentences, profile))
306
+ lines.append("")
307
+ return "\n".join(lines) + "\n"
308
+
309
+
310
+ def _never_does(profile):
311
+ never = []
312
+ p = profile["punctuation"]
313
+ label = {";": "semicolons", "!": "exclamation points", ":": "colons",
314
+ "-": "hyphenated dashes", "(": "parentheticals"}
315
+ for mark, name in label.items():
316
+ if p.get(mark, 0.0) == 0.0:
317
+ never.append(name)
318
+ return never
319
+
320
+
321
+ def build_card(profile, docs, matrix, name):
322
+ med = int(profile["sentence_lengths"]["median"])
323
+ iqr = int(profile["sentence_lengths"]["iqr"])
324
+ contr = profile["contraction_rate"]
325
+ examples = _contraction_examples(docs)
326
+ openers = _opener_words(docs)
327
+ never = _never_does(profile)
328
+ covered = [d for d in TAXONOMY if matrix[d]["covered"]]
329
+ uncovered = [d for d in TAXONOMY if not matrix[d]["covered"]]
330
+
331
+ lines = [f"# Voice card: {name}", ""]
332
+ lines.append(
333
+ f"Rhythm: median sentence {med} words, IQR {iqr}, {_burstiness(profile)}. "
334
+ f"Paragraphs average {int(profile['paragraph_stats']['mean_words'])} words."
335
+ )
336
+ if examples:
337
+ lines.append(
338
+ f"Contractions: rate {contr:.3f}; e.g. " + ", ".join(examples) + "."
339
+ )
340
+ else:
341
+ lines.append(f"Contractions: rate {contr:.3f}; rarely contracts.")
342
+ if never:
343
+ lines.append("Never: " + "; ".join(never) + ".")
344
+ lines.append("Openers: " + ", ".join(openers) + ".")
345
+ lines.append("")
346
+ lines.append("Match rhythm and habits first; keep facts and meaning intact.")
347
+ lines.append("")
348
+ lines.append("## When writing, read the matching sheet")
349
+ lines.append("")
350
+ lines.append("| Situation | Sheet |")
351
+ lines.append("|-----------|-------|")
352
+ for dim in covered:
353
+ lines.append(f"| {dim.replace('-', ' ')} | card/{dim}.md |")
354
+ lines.append("")
355
+ if uncovered:
356
+ lines.append(
357
+ "Uncovered (no sample evidence — do not fabricate a voice for these): "
358
+ + ", ".join(uncovered) + "."
359
+ )
360
+ return "\n".join(lines) + "\n"
361
+
362
+
363
+ def card_word_count(card_text):
364
+ return len(re.findall(r"[A-Za-z0-9']+", card_text))
365
+
366
+
367
+ def write_card(profile, samples_dir, out_dir, name):
368
+ docs = collect(samples_dir)
369
+ matrix, buckets = coverage_matrix(docs)
370
+ out = Path(out_dir)
371
+ (out / "card").mkdir(parents=True, exist_ok=True)
372
+ # Remove any stale sheets so uncovered dims never keep an old file.
373
+ for stale in (out / "card").glob("*.md"):
374
+ stale.unlink()
375
+ card = build_card(profile, docs, matrix, name)
376
+ (out / "card.md").write_text(card)
377
+ for dim in TAXONOMY:
378
+ if matrix[dim]["covered"]:
379
+ (out / "card" / f"{dim}.md").write_text(build_sheet(dim, buckets[dim], profile))
380
+ return matrix
381
+
382
+
383
+ def write_provenance(profile, samples_dir, out_dir):
384
+ samples = []
385
+ total = 0
386
+ for path in voice_profile.iter_docs(samples_dir):
387
+ text = path.read_text(errors="replace")
388
+ wc = len(voice_profile.words(text))
389
+ total += wc
390
+ samples.append({
391
+ "file": path.name,
392
+ "sha256": sha256_file(path),
393
+ "words": wc,
394
+ })
395
+ meta = profile.get("metadata", {})
396
+ prov = {
397
+ "doc_count": len(samples),
398
+ "total_words": total,
399
+ "samples": samples,
400
+ "genre_note": meta.get("genre_warning", "") or "same-genre samples assumed",
401
+ "low_confidence": bool(meta.get("low_confidence", total < 2000)),
402
+ }
403
+ Path(out_dir, "provenance.json").write_text(
404
+ json.dumps(prov, indent=2, sort_keys=True) + "\n"
405
+ )
406
+ return prov
407
+
408
+
409
+ def profile_mismatch(supplied, recomputed, path=""):
410
+ """First named field where the supplied profile disagrees with one recomputed
411
+ from --samples, or None. Counts (ints) compare exactly; floats within 1e-6.
412
+ The function-word background is a normalizer (from --background, not the
413
+ sample content), so it is not part of the equality check."""
414
+ here = path or "<root>"
415
+ if isinstance(supplied, bool) or isinstance(recomputed, bool):
416
+ return None if supplied == recomputed else here
417
+ if isinstance(supplied, dict):
418
+ if not isinstance(recomputed, dict):
419
+ return here
420
+ for k in sorted(set(supplied) | set(recomputed)):
421
+ if k == "function_word_background":
422
+ continue
423
+ if k not in supplied or k not in recomputed:
424
+ return f"{path}.{k}".lstrip(".")
425
+ m = profile_mismatch(supplied[k], recomputed[k], f"{path}.{k}".lstrip("."))
426
+ if m:
427
+ return m
428
+ return None
429
+ if isinstance(supplied, list):
430
+ if not isinstance(recomputed, list) or len(supplied) != len(recomputed):
431
+ return here
432
+ for i, (x, y) in enumerate(zip(supplied, recomputed)):
433
+ m = profile_mismatch(x, y, f"{path}[{i}]")
434
+ if m:
435
+ return m
436
+ return None
437
+ if isinstance(supplied, int) and isinstance(recomputed, int):
438
+ return None if supplied == recomputed else here
439
+ if isinstance(supplied, (int, float)) and isinstance(recomputed, (int, float)):
440
+ return None if abs(supplied - recomputed) <= 1e-6 else here
441
+ return None if supplied == recomputed else here
442
+
443
+
444
+ def parse_args(argv):
445
+ parser = argparse.ArgumentParser(description=__doc__)
446
+ parser.add_argument("--profile", required=True)
447
+ parser.add_argument("--samples", required=True)
448
+ parser.add_argument("--out")
449
+ parser.add_argument("--name", default="voice")
450
+ parser.add_argument("--coverage", action="store_true",
451
+ help="print the coverage matrix as JSON and write nothing")
452
+ parser.add_argument("--provenance", action="store_true",
453
+ help="also write <out>/provenance.json")
454
+ return parser.parse_args(argv)
455
+
456
+
457
+ def main(argv):
458
+ args = parse_args(argv)
459
+ profile_path = Path(args.profile)
460
+ samples_dir = Path(args.samples)
461
+ if not profile_path.exists() or not samples_dir.is_dir():
462
+ print("missing profile or samples dir", file=sys.stderr)
463
+ return 2
464
+ if not list(voice_profile.iter_docs(samples_dir)):
465
+ print(f"no sample documents in {samples_dir}: teach reads only .txt and .md "
466
+ f"files (recursively). Rename samples to .txt/.md or point --samples at "
467
+ f"the right directory.", file=sys.stderr)
468
+ return 2
469
+ profile = json.loads(profile_path.read_text())
470
+
471
+ # Consistency gate: the supplied profile must describe these very samples.
472
+ recomputed = voice_profile.build_profile(samples_dir)
473
+ mismatch = profile_mismatch(profile, recomputed)
474
+ if mismatch is not None:
475
+ print(f"profile does not match --samples (recompute differs at '{mismatch}'); "
476
+ f"rebuild the profile from these samples with voice_profile.py",
477
+ file=sys.stderr)
478
+ return 2
479
+
480
+ if args.coverage:
481
+ docs = collect(samples_dir)
482
+ matrix, _ = coverage_matrix(docs)
483
+ print(json.dumps(matrix, indent=2, sort_keys=True))
484
+ return 0
485
+
486
+ if not args.out:
487
+ print("--out is required unless --coverage", file=sys.stderr)
488
+ return 2
489
+ write_card(profile, samples_dir, args.out, args.name)
490
+ if args.provenance:
491
+ write_provenance(profile, samples_dir, args.out)
492
+ return 0
493
+
494
+
495
+ if __name__ == "__main__":
496
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """Build deterministic stylometric voice profiles.
3
+
4
+ The built-in background table is a compact Writeprints-style English baseline:
5
+ common function words seeded from public-domain frequency lists used by classic
6
+ authorship-attribution examples, with broad fallback means/stddevs for words not
7
+ observed in a caller-supplied background corpus. It is meant only as a stable
8
+ normalizer; pass --background with same-genre documents for calibrated work.
9
+ """
10
+
11
+ import argparse
12
+ import collections
13
+ import json
14
+ import math
15
+ import re
16
+ import statistics
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ FUNCTION_WORDS = """
21
+ the of and to in a is that it for as with was on be by he i this are or his from at
22
+ which but have an had they you were their one all we can her has there been if more
23
+ when will would who so no she about out up into do any your what than them some could
24
+ these other then its our two may first my now such like over only also after most did
25
+ many before must through back where much should well people down own just because good
26
+ each those how under see made very being make between both even another while last
27
+ might still same never every against since off though yet without within upon among
28
+ until during per either neither nor whether whose whom why again once here there
29
+ therefore however although nevertheless moreover instead indeed perhaps rather thus
30
+ else already almost around across behind beyond near toward towards above below beside
31
+ inside outside along plus minus except despite via versus including regarding concerning
32
+ am were does done doing having let lets cannot dont didn't doesn't isn't aren't wasn't
33
+ weren't haven't hasn't hadn't won't wouldn't shouldn't couldn't mightn't mustn't i'm
34
+ you're he's she's it's we're they're i've you've we've they've i'd you'd he'd she'd we'd
35
+ they'd i'll you'll he'll she'll we'll they'll me him us mine yours ours theirs myself
36
+ yourself himself herself itself ourselves yourselves themselves
37
+ """.split()
38
+
39
+ PUNCT = [",", ".", ";", ":", "?", "!", "-", "(", ")", '"', "'"]
40
+ WORD_RE = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+")
41
+ SENT_RE = re.compile(r"[^.!?]+[.!?]?")
42
+
43
+
44
+ def iter_docs(root):
45
+ for path in sorted(Path(root).rglob("*")):
46
+ if path.suffix.lower() in {".txt", ".md"} and path.is_file():
47
+ yield path
48
+
49
+
50
+ def normalize(text):
51
+ return re.sub(r"\s+", " ", text.lower()).strip()
52
+
53
+
54
+ def words(text):
55
+ return WORD_RE.findall(text.lower())
56
+
57
+
58
+ def sentences(text):
59
+ out = []
60
+ for part in SENT_RE.findall(text):
61
+ toks = words(part)
62
+ if toks:
63
+ out.append(toks)
64
+ return out
65
+
66
+
67
+ def char3_counts(text, limit=None):
68
+ norm = normalize(text)
69
+ grams = collections.Counter(norm[i:i + 3] for i in range(max(0, len(norm) - 2)))
70
+ items = sorted(grams.items(), key=lambda kv: (-kv[1], kv[0]))
71
+ if limit:
72
+ items = items[:limit]
73
+ return dict(items)
74
+
75
+
76
+ def function_freq(tokens):
77
+ total = max(1, len(tokens))
78
+ counts = collections.Counter(tokens)
79
+ return {w: counts[w] / total for w in FUNCTION_WORDS}
80
+
81
+
82
+ def sentence_stats(text):
83
+ lengths = [len(s) for s in sentences(text)]
84
+ if not lengths:
85
+ return {"lengths": [], "median": 0.0, "iqr": 0.0}
86
+ ordered = sorted(lengths)
87
+ mid = statistics.median(ordered)
88
+ q1 = statistics.median(ordered[:len(ordered) // 2] or ordered)
89
+ q3 = statistics.median(ordered[(len(ordered) + 1) // 2:] or ordered)
90
+ return {"lengths": lengths, "median": mid, "iqr": q3 - q1}
91
+
92
+
93
+ def mtld(tokens, threshold=0.72):
94
+ if len(tokens) < 20:
95
+ return 0.0
96
+ factors = 0.0
97
+ types = set()
98
+ count = 0
99
+ for tok in tokens:
100
+ count += 1
101
+ types.add(tok)
102
+ if len(types) / count <= threshold:
103
+ factors += 1
104
+ types.clear()
105
+ count = 0
106
+ if count:
107
+ ttr = len(types) / count
108
+ factors += (1 - ttr) / (1 - threshold) if threshold < 1 else 0
109
+ return len(tokens) / factors if factors else float(len(tokens))
110
+
111
+
112
+ def feature_bundle(text):
113
+ toks = words(text)
114
+ total = max(1, len(toks))
115
+ punct_counts = collections.Counter(ch for ch in text if ch in PUNCT)
116
+ contractions = sum(1 for t in toks if "'" in t)
117
+ hist = collections.Counter(min(len(t), 15) for t in toks)
118
+ paragraphs = [p for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
119
+ return {
120
+ "char3": char3_counts(text, 2000),
121
+ "function_words": function_freq(toks),
122
+ "sentence_lengths": sentence_stats(text),
123
+ "punctuation": {p: punct_counts[p] / total for p in PUNCT},
124
+ "contraction_rate": contractions / total,
125
+ "mtld": mtld(toks),
126
+ "word_length_histogram": {str(i): hist[i] / total for i in range(1, 16)},
127
+ "paragraph_stats": {
128
+ "count": len(paragraphs),
129
+ "mean_words": (sum(len(words(p)) for p in paragraphs) / len(paragraphs)) if paragraphs else 0.0,
130
+ },
131
+ "total_words": len(toks),
132
+ }
133
+
134
+
135
+ def background_stats(root=None):
136
+ docs = []
137
+ if root:
138
+ docs = [p.read_text(errors="replace") for p in iter_docs(root)]
139
+ if not docs:
140
+ return {w: {"mean": 0.0025 if w not in {"the", "of", "and", "to", "in", "a"} else 0.025,
141
+ "std": 0.006} for w in FUNCTION_WORDS}
142
+ rows = [function_freq(words(text)) for text in docs]
143
+ stats = {}
144
+ for w in FUNCTION_WORDS:
145
+ vals = [r[w] for r in rows]
146
+ stats[w] = {
147
+ "mean": statistics.mean(vals),
148
+ "std": statistics.pstdev(vals) or 0.0001,
149
+ }
150
+ return stats
151
+
152
+
153
+ def build_profile(samples_dir, background=None):
154
+ paths = list(iter_docs(samples_dir))
155
+ text = "\n\n".join(p.read_text(errors="replace") for p in paths)
156
+ profile = feature_bundle(text)
157
+ profile["function_word_background"] = background_stats(background)
158
+ profile["metadata"] = {
159
+ "doc_count": len(paths),
160
+ "total_words": profile["total_words"],
161
+ "low_confidence": profile["total_words"] < 2000,
162
+ "genre_warning": "profile has fewer than 2000 words" if profile["total_words"] < 2000 else "",
163
+ }
164
+ return profile
165
+
166
+
167
+ def parse_args(argv):
168
+ parser = argparse.ArgumentParser()
169
+ parser.add_argument("samples_dir")
170
+ parser.add_argument("-o", "--output", required=True)
171
+ parser.add_argument("--background")
172
+ return parser.parse_args(argv)
173
+
174
+
175
+ def main(argv):
176
+ args = parse_args(argv)
177
+ root = Path(args.samples_dir)
178
+ if not root.is_dir():
179
+ print(f"missing samples dir: {root}", file=sys.stderr)
180
+ return 2
181
+ if not list(iter_docs(root)):
182
+ print(f"no sample documents in {root}: only .txt and .md files are read "
183
+ f"(recursively). Rename samples to .txt/.md or point at the right "
184
+ f"directory.", file=sys.stderr)
185
+ return 2
186
+ profile = build_profile(root, args.background)
187
+ if profile["metadata"]["low_confidence"]:
188
+ print(profile["metadata"]["genre_warning"], file=sys.stderr)
189
+ Path(args.output).write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n")
190
+ return 0
191
+
192
+
193
+ if __name__ == "__main__":
194
+ raise SystemExit(main(sys.argv[1:]))