patina-cli 3.11.0

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 (180) hide show
  1. package/.patina.default.yaml +211 -0
  2. package/CHANGELOG.md +265 -0
  3. package/LICENSE +21 -0
  4. package/README.md +319 -0
  5. package/README_JA.md +254 -0
  6. package/README_KR.md +253 -0
  7. package/README_ZH.md +254 -0
  8. package/SKILL-MAX.md +455 -0
  9. package/SKILL.md +730 -0
  10. package/assets/brand/patina-icon.svg +9 -0
  11. package/assets/brand/patina-logo.svg +17 -0
  12. package/assets/social/patina-before-after.svg +46 -0
  13. package/assets/social/patina-og.svg +31 -0
  14. package/bin/patina.js +9 -0
  15. package/core/scoring.md +657 -0
  16. package/core/standalone-prompt.md +364 -0
  17. package/core/stylometry.md +754 -0
  18. package/core/voice.md +163 -0
  19. package/docs/AUTHENTICATION.md +105 -0
  20. package/docs/AUTHENTICATION_KR.md +105 -0
  21. package/docs/BRANDING.md +37 -0
  22. package/docs/CLI.md +80 -0
  23. package/docs/COMPARISON.md +38 -0
  24. package/docs/COOKBOOK.md +173 -0
  25. package/docs/DEMO.md +40 -0
  26. package/docs/ETHICS.md +27 -0
  27. package/docs/EXAMPLES.md +130 -0
  28. package/docs/EXAMPLES_KR.md +130 -0
  29. package/docs/EXIT-CODES.md +25 -0
  30. package/docs/FAQ.md +67 -0
  31. package/docs/FAQ_KR.md +65 -0
  32. package/docs/FLAG-PARITY.md +53 -0
  33. package/docs/GLOSSARY.md +123 -0
  34. package/docs/PATTERNS-EN.md +718 -0
  35. package/docs/PATTERNS-JA.md +706 -0
  36. package/docs/PATTERNS-KO.md +707 -0
  37. package/docs/PATTERNS-ZH.md +706 -0
  38. package/docs/PATTERNS.md +22 -0
  39. package/docs/ROADMAP.md +315 -0
  40. package/docs/audits/2026-05-deep-research.md +290 -0
  41. package/docs/benchmarks/detector-comparison.json +442 -0
  42. package/docs/benchmarks/detector-comparison.md +65 -0
  43. package/docs/benchmarks/latest.json +988 -0
  44. package/docs/benchmarks/latest.md +112 -0
  45. package/docs/integrations/docker.md +19 -0
  46. package/docs/integrations/github-action.md +59 -0
  47. package/docs/integrations/pre-commit.md +77 -0
  48. package/docs/integrations/release.md +43 -0
  49. package/docs/internal/HARNESS.md +14 -0
  50. package/docs/internal/README.md +14 -0
  51. package/docs/internal/WARP.md +23 -0
  52. package/docs/research/2025-rebaseline-plan.md +89 -0
  53. package/docs/research/ai-human-metrics.md +380 -0
  54. package/docs/social/gstack-cardnews.html +236 -0
  55. package/docs/social/gstack-cardnews.md +88 -0
  56. package/docs/social/gstack-thread.md +106 -0
  57. package/docs/social/patina-launch-copy.md +227 -0
  58. package/docs/superpowers/specs/2026-04-03-meaning-preservation-design.md +299 -0
  59. package/lexicon/ai-en.md +162 -0
  60. package/lexicon/ai-ko.md +159 -0
  61. package/package.json +100 -0
  62. package/patina-max/SKILL.md +523 -0
  63. package/patina-max/composite.py +457 -0
  64. package/patterns/en-communication.md +89 -0
  65. package/patterns/en-content.md +133 -0
  66. package/patterns/en-filler.md +113 -0
  67. package/patterns/en-language.md +163 -0
  68. package/patterns/en-structure.md +173 -0
  69. package/patterns/en-style.md +139 -0
  70. package/patterns/en-viral-hook.md +211 -0
  71. package/patterns/ja-communication.md +101 -0
  72. package/patterns/ja-content.md +153 -0
  73. package/patterns/ja-filler.md +123 -0
  74. package/patterns/ja-language.md +190 -0
  75. package/patterns/ja-structure.md +142 -0
  76. package/patterns/ja-style.md +147 -0
  77. package/patterns/ja-viral-hook.md +216 -0
  78. package/patterns/ko-communication.md +98 -0
  79. package/patterns/ko-content.md +154 -0
  80. package/patterns/ko-filler.md +105 -0
  81. package/patterns/ko-language.md +182 -0
  82. package/patterns/ko-structure.md +147 -0
  83. package/patterns/ko-style.md +146 -0
  84. package/patterns/ko-viral-hook.md +211 -0
  85. package/patterns/zh-communication.md +101 -0
  86. package/patterns/zh-content.md +153 -0
  87. package/patterns/zh-filler.md +118 -0
  88. package/patterns/zh-language.md +173 -0
  89. package/patterns/zh-structure.md +145 -0
  90. package/patterns/zh-style.md +159 -0
  91. package/patterns/zh-viral-hook.md +216 -0
  92. package/profiles/academic.md +53 -0
  93. package/profiles/blog.md +81 -0
  94. package/profiles/casual-conversation.md +105 -0
  95. package/profiles/code-comment.md +104 -0
  96. package/profiles/commit-message.md +99 -0
  97. package/profiles/default.md +62 -0
  98. package/profiles/email.md +52 -0
  99. package/profiles/formal.md +98 -0
  100. package/profiles/instructional.md +80 -0
  101. package/profiles/legal.md +57 -0
  102. package/profiles/marketing.md +56 -0
  103. package/profiles/medical.md +53 -0
  104. package/profiles/narrative.md +79 -0
  105. package/profiles/release-notes.md +98 -0
  106. package/profiles/social.md +56 -0
  107. package/profiles/technical.md +53 -0
  108. package/scripts/benchmark-report.mjs +252 -0
  109. package/scripts/check-release-metadata.mjs +48 -0
  110. package/scripts/detector-comparison.mjs +267 -0
  111. package/scripts/lint.mjs +40 -0
  112. package/scripts/precommit-score.mjs +31 -0
  113. package/scripts/prose-score.mjs +186 -0
  114. package/scripts/update-benchmark-ranges.mjs +108 -0
  115. package/src/api.js +330 -0
  116. package/src/auth.js +105 -0
  117. package/src/backends/claude-cli.js +112 -0
  118. package/src/backends/codex-cli.js +121 -0
  119. package/src/backends/contract.js +21 -0
  120. package/src/backends/gemini-cli.js +135 -0
  121. package/src/backends/index.js +159 -0
  122. package/src/cache.js +106 -0
  123. package/src/cli.js +1280 -0
  124. package/src/commands/doctor.js +229 -0
  125. package/src/commands/init.js +208 -0
  126. package/src/config.js +126 -0
  127. package/src/errors.js +53 -0
  128. package/src/features/index.js +96 -0
  129. package/src/features/lexicon.js +90 -0
  130. package/src/features/segment.js +49 -0
  131. package/src/features/stylometry.js +50 -0
  132. package/src/loader.js +103 -0
  133. package/src/logger.js +70 -0
  134. package/src/manifest.js +162 -0
  135. package/src/max-mode.js +207 -0
  136. package/src/ouroboros.js +233 -0
  137. package/src/output.js +480 -0
  138. package/src/prompt-builder.js +409 -0
  139. package/src/providers.js +100 -0
  140. package/src/scoring.js +531 -0
  141. package/src/security.js +133 -0
  142. package/tests/fixtures/suspect-zones/en/ai/en-ai-01.md +16 -0
  143. package/tests/fixtures/suspect-zones/en/ai/en-ai-02.md +16 -0
  144. package/tests/fixtures/suspect-zones/en/ai/en-ai-03.md +17 -0
  145. package/tests/fixtures/suspect-zones/en/ai/en-ai-04.md +15 -0
  146. package/tests/fixtures/suspect-zones/en/ai/en-ai-05.md +16 -0
  147. package/tests/fixtures/suspect-zones/en/ai/en-ai-06-chat-register.md +16 -0
  148. package/tests/fixtures/suspect-zones/en/natural/en-nat-01.md +15 -0
  149. package/tests/fixtures/suspect-zones/en/natural/en-nat-02.md +15 -0
  150. package/tests/fixtures/suspect-zones/en/natural/en-nat-03.md +15 -0
  151. package/tests/fixtures/suspect-zones/en/natural/en-nat-04.md +15 -0
  152. package/tests/fixtures/suspect-zones/en/natural/en-nat-05.md +15 -0
  153. package/tests/fixtures/suspect-zones/expected-ranges.json +939 -0
  154. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-01.md +11 -0
  155. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-02.md +11 -0
  156. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-03.md +11 -0
  157. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-01.md +11 -0
  158. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-02.md +11 -0
  159. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-03.md +11 -0
  160. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-01.md +14 -0
  161. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +16 -0
  162. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-03.md +15 -0
  163. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-04.md +15 -0
  164. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-05.md +16 -0
  165. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-06-chat-register.md +16 -0
  166. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-01.md +15 -0
  167. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-02.md +15 -0
  168. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-03.md +15 -0
  169. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-04.md +14 -0
  170. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-05.md +15 -0
  171. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-01.md +11 -0
  172. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-02.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-03.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-01.md +11 -0
  175. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-02.md +11 -0
  176. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-03.md +11 -0
  177. package/tests/quality/README.md +121 -0
  178. package/tests/quality/benchmark.mjs +306 -0
  179. package/tests/quality/detectors.manual.example.json +31 -0
  180. package/tests/quality/dogfood.mjs +44 -0
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env python3
2
+ """patina-max composite: deterministic 4-axis winner reselection over an
3
+ existing patina-max run directory.
4
+
5
+ The default patina-max winner picker only sees AI-likeness and MPS, so it
6
+ goes noise-bound when a baseline is already humanized. This script adds two
7
+ Korean-aware deterministic metrics — Register Stability Score (RSS) and
8
+ Edit Conservativeness (EditCons) — and reselects the winner.
9
+
10
+ Usage
11
+ -----
12
+ python3 patina-max/composite.py <run_dir> [--weights ...]
13
+
14
+ Layout consumed
15
+ ---------------
16
+ <run_dir>/
17
+ input.md baseline source MDX (required)
18
+ claude.md candidate (optional; absent → "missing")
19
+ gemini.md candidate (optional)
20
+ codex.md candidate (optional; may be a failure note)
21
+ meta.md YAML; per-candidate ai_score / mps / status (recommended)
22
+
23
+ Layout produced
24
+ ---------------
25
+ <run_dir>/
26
+ composite.md per-candidate metric table + weighted totals
27
+ winner.md winning candidate's text (or a none-found notice)
28
+
29
+ Default weights (renormalised after dropping the LLM-Judge slot):
30
+
31
+ AI=0.353 MPS=0.235 RSS=0.235 EditCons=0.176
32
+
33
+ Override via .patina.default.yaml:
34
+
35
+ composite-weights:
36
+ ai: 0.353
37
+ mps: 0.235
38
+ rss: 0.235
39
+ edit_cons: 0.176
40
+
41
+ Or inline:
42
+
43
+ python3 patina-max/composite.py <run_dir> --weights ai=0.4,rss=0.3
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import difflib
50
+ import math
51
+ import re
52
+ import sys
53
+ from collections import Counter
54
+ from dataclasses import dataclass, field
55
+ from pathlib import Path
56
+ from typing import Optional
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Korean register / edit metrics
61
+ # ---------------------------------------------------------------------------
62
+
63
+ # Sentence-final ending vocabulary. Order matters — longer forms first so the
64
+ # regex engine matches `합니다` before falling back to `다`.
65
+ _ENDING_PATTERNS = [
66
+ # 합쇼체 (deferential formal): ~ㅂ니다 / ~습니다 / ~ㅂ니까 / ~습니까 / ~십시오
67
+ ("hapsho", r"(?:[가-힣]니다|[가-힣]니까|[가-힣]시오|십시오|십시요)"),
68
+ # 해요체 (polite informal)
69
+ ("haeyo", r"(?:세요|예요|이에요|에요|해요|어요|아요|네요|군요|지요|죠|[가-힣]요)"),
70
+ # 해라체 (plain declarative / imperative)
71
+ ("haera", r"(?:[가-힣]는다|한다|[가-힣]다|하라|마라|보라|들라|[가-힣]아라|[가-힣]어라|[가-힣]라)"),
72
+ # 해체 (casual / 반말)
73
+ ("hae", r"(?:해|야|아|어|네|군|지)"),
74
+ ]
75
+
76
+ _SENTENCE_SPLIT = re.compile(r"[.!?。]+\s+|\n+")
77
+ _TRAILING_PUNCT = re.compile(r"[\s.,!?;:。、]+$")
78
+
79
+
80
+ def _strip_markdown_noise(text: str) -> str:
81
+ """Drop fenced code blocks, JSX tags, image lines, and href payloads.
82
+
83
+ Composite metrics are about Korean prose. MDX fences and JSX scaffolding
84
+ would otherwise inflate the token count and skew Edit Conservativeness.
85
+ """
86
+ text = re.sub(r"```[\s\S]*?```", "", text)
87
+ text = re.sub(r"<[A-Z][\w]*\b[^>]*?/?>", "", text)
88
+ text = re.sub(r"</[A-Z][\w]*>", "", text)
89
+ text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
90
+ text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
91
+ text = re.sub(r"\A---\n[\s\S]*?\n---\n", "", text)
92
+ return text
93
+
94
+
95
+ def _split_sentences(text: str) -> list[str]:
96
+ cleaned = _strip_markdown_noise(text)
97
+ parts = _SENTENCE_SPLIT.split(cleaned)
98
+ sentences: list[str] = []
99
+ for part in parts:
100
+ for line in part.splitlines():
101
+ line = line.strip()
102
+ if not line:
103
+ continue
104
+ line = re.sub(r"^\s*([>#\-*]+\s*)+", "", line)
105
+ line = re.sub(r"^\*\*[^*]+\*\*[\s:—-]*", "", line)
106
+ line = line.strip()
107
+ if line:
108
+ sentences.append(line)
109
+ return sentences
110
+
111
+
112
+ def ending_distribution(text: str) -> Counter[str]:
113
+ dist: Counter[str] = Counter()
114
+ for sentence in _split_sentences(text):
115
+ tail = _TRAILING_PUNCT.sub("", sentence)
116
+ if not tail:
117
+ continue
118
+ bucket = "other"
119
+ for name, pattern in _ENDING_PATTERNS:
120
+ if re.search(pattern + r"$", tail):
121
+ bucket = name
122
+ break
123
+ dist[bucket] += 1
124
+ return dist
125
+
126
+
127
+ def cosine_similarity(a: Counter[str], b: Counter[str]) -> float:
128
+ keys = set(a) | set(b)
129
+ if not keys:
130
+ return 0.0
131
+ dot = sum(a[k] * b[k] for k in keys)
132
+ norm_a = math.sqrt(sum(v * v for v in a.values()))
133
+ norm_b = math.sqrt(sum(v * v for v in b.values()))
134
+ if norm_a == 0 or norm_b == 0:
135
+ return 0.0
136
+ return dot / (norm_a * norm_b)
137
+
138
+
139
+ def register_stability(baseline: str, candidate: str) -> float:
140
+ """RSS: cosine similarity of register distributions, scaled to 0-100."""
141
+ return cosine_similarity(ending_distribution(baseline), ending_distribution(candidate)) * 100.0
142
+
143
+
144
+ def edit_conservativeness(baseline: str, candidate: str) -> float:
145
+ """EditCons: SequenceMatcher ratio on whitespace tokens (0-100)."""
146
+ base_tokens = _strip_markdown_noise(baseline).split()
147
+ cand_tokens = _strip_markdown_noise(candidate).split()
148
+ if not base_tokens and not cand_tokens:
149
+ return 100.0
150
+ if not base_tokens or not cand_tokens:
151
+ return 0.0
152
+ matcher = difflib.SequenceMatcher(None, base_tokens, cand_tokens, autojunk=False)
153
+ return matcher.ratio() * 100.0
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Composite scoring + run-dir IO
158
+ # ---------------------------------------------------------------------------
159
+
160
+ DEFAULT_WEIGHTS = {
161
+ "ai": 0.353,
162
+ "mps": 0.235,
163
+ "rss": 0.235,
164
+ "edit_cons": 0.176,
165
+ }
166
+
167
+ CANDIDATE_MODELS = ("claude", "gemini", "codex")
168
+ RUN_FRONTMATTER = re.compile(r"\A---\n([\s\S]*?)\n---\n", re.MULTILINE)
169
+ NUMBER_RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*[-–~]\s*(\d+(?:\.\d+)?)")
170
+ SINGLE_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
171
+ NON_NUMERIC_PLACEHOLDERS = {"n/a", "na", "none", "—", "-", "pending", "tbd", "unknown"}
172
+
173
+
174
+ @dataclass
175
+ class Candidate:
176
+ model: str
177
+ text: str
178
+ ai_score: Optional[float] = None
179
+ mps: Optional[float] = None
180
+ rss: Optional[float] = None
181
+ edit_cons: Optional[float] = None
182
+ composite: Optional[float] = None
183
+ status: str = "unknown"
184
+ notes: list[str] = field(default_factory=list)
185
+
186
+
187
+ def parse_metric(raw: Optional[str]) -> Optional[float]:
188
+ """Coerce metric strings from meta.md into floats.
189
+
190
+ `0-2 (within noise floor)` -> 1.0 (midpoint)
191
+ `92 (all anchors preserved)` -> 92.0
192
+ `n/a` / `pending` / `—` -> None
193
+ """
194
+ if raw is None:
195
+ return None
196
+ raw = str(raw).strip().strip('"').strip("'")
197
+ if not raw or raw.lower() in NON_NUMERIC_PLACEHOLDERS:
198
+ return None
199
+ range_match = NUMBER_RANGE.search(raw)
200
+ if range_match:
201
+ return (float(range_match.group(1)) + float(range_match.group(2))) / 2.0
202
+ single_match = SINGLE_NUMBER.search(raw)
203
+ if single_match:
204
+ return float(single_match.group(1))
205
+ return None
206
+
207
+
208
+ def parse_meta_candidates(meta_text: str) -> dict[str, dict[str, str]]:
209
+ """Pull per-candidate score lines from meta.md without a YAML library."""
210
+ info: dict[str, dict[str, str]] = {}
211
+ in_candidates = False
212
+ current: Optional[dict[str, str]] = None
213
+ for raw_line in meta_text.splitlines():
214
+ line = raw_line.rstrip()
215
+ if not line.startswith(" ") and line.endswith(":"):
216
+ in_candidates = line.strip() == "candidates:"
217
+ current = None
218
+ continue
219
+ if not in_candidates:
220
+ continue
221
+ stripped = line.lstrip()
222
+ if stripped.startswith("- model:"):
223
+ model = stripped.split(":", 1)[1].strip()
224
+ current = {"model": model}
225
+ info[model] = current
226
+ continue
227
+ if current is None:
228
+ continue
229
+ if ":" not in stripped:
230
+ continue
231
+ key, value = stripped.split(":", 1)
232
+ key = key.strip()
233
+ value = value.strip()
234
+ if value == "|":
235
+ value = "<multiline>"
236
+ if key in {"ai_score", "ai_score_instructional", "ai_score_technical", "mps", "status", "wall_time_seconds"}:
237
+ current[key] = value
238
+ return info
239
+
240
+
241
+ def read_candidate_text(path: Path) -> str:
242
+ if not path.exists():
243
+ return ""
244
+ text = path.read_text(encoding="utf-8")
245
+ return RUN_FRONTMATTER.sub("", text, count=1)
246
+
247
+
248
+ def normalise_weights(weights: dict[str, float]) -> dict[str, float]:
249
+ total = sum(weights.values())
250
+ if total <= 0:
251
+ raise ValueError("weights must sum to a positive number")
252
+ return {k: v / total for k, v in weights.items()}
253
+
254
+
255
+ def parse_weight_overrides(spec: str) -> dict[str, float]:
256
+ overrides: dict[str, float] = {}
257
+ for chunk in spec.split(","):
258
+ chunk = chunk.strip()
259
+ if not chunk:
260
+ continue
261
+ if "=" not in chunk:
262
+ raise ValueError(f"invalid weight override `{chunk}` (expected key=value)")
263
+ key, value = chunk.split("=", 1)
264
+ key = key.strip().lower()
265
+ if key not in DEFAULT_WEIGHTS:
266
+ raise ValueError(f"unknown weight key `{key}`; valid: {sorted(DEFAULT_WEIGHTS)}")
267
+ try:
268
+ overrides[key] = float(value)
269
+ except ValueError as exc:
270
+ raise ValueError(f"weight `{key}` not a number: {value}") from exc
271
+ return overrides
272
+
273
+
274
+ def load_yaml_weights(yaml_path: Path) -> dict[str, float]:
275
+ """Pick `composite-weights:` out of the patina config without PyYAML."""
276
+ if not yaml_path.exists():
277
+ return {}
278
+ weights: dict[str, float] = {}
279
+ in_block = False
280
+ for raw_line in yaml_path.read_text(encoding="utf-8").splitlines():
281
+ if raw_line.startswith("composite-weights:"):
282
+ in_block = True
283
+ continue
284
+ if in_block:
285
+ if not raw_line.startswith(" "):
286
+ break
287
+ stripped = raw_line.strip()
288
+ if not stripped or stripped.startswith("#"):
289
+ continue
290
+ if ":" not in stripped:
291
+ break
292
+ key, value = stripped.split(":", 1)
293
+ key = key.strip().lower()
294
+ try:
295
+ weights[key] = float(value.strip().split("#", 1)[0].strip())
296
+ except ValueError:
297
+ continue
298
+ return {k: v for k, v in weights.items() if k in DEFAULT_WEIGHTS}
299
+
300
+
301
+ def resolve_weights(yaml_path: Path, cli_override: Optional[str]) -> dict[str, float]:
302
+ weights = dict(DEFAULT_WEIGHTS)
303
+ weights.update(load_yaml_weights(yaml_path))
304
+ if cli_override:
305
+ weights.update(parse_weight_overrides(cli_override))
306
+ return normalise_weights(weights)
307
+
308
+
309
+ def composite_score(candidate: Candidate, weights: dict[str, float]) -> Optional[float]:
310
+ if candidate.status != "success":
311
+ return None
312
+ if any(v is None for v in (candidate.ai_score, candidate.mps, candidate.rss, candidate.edit_cons)):
313
+ return None
314
+ return (
315
+ (100.0 - candidate.ai_score) * weights["ai"]
316
+ + candidate.mps * weights["mps"]
317
+ + candidate.rss * weights["rss"]
318
+ + candidate.edit_cons * weights["edit_cons"]
319
+ )
320
+
321
+
322
+ def render_composite_md(
323
+ run_dir: Path,
324
+ weights: dict[str, float],
325
+ candidates: list[Candidate],
326
+ winner: Optional[Candidate],
327
+ ) -> str:
328
+ lines: list[str] = []
329
+ lines.append(f"# patina-composite scores for `{run_dir.name}`")
330
+ lines.append("")
331
+ lines.append("Generated by `patina-max/composite.py` — deterministic 4-axis reselection.")
332
+ lines.append("")
333
+ lines.append("## Weights")
334
+ lines.append("")
335
+ lines.append("| Axis | Weight |")
336
+ lines.append("|------|-------:|")
337
+ for key in ("ai", "mps", "rss", "edit_cons"):
338
+ lines.append(f"| {key} | {weights[key]:.4f} |")
339
+ lines.append("")
340
+ lines.append("## Candidate scores")
341
+ lines.append("")
342
+ lines.append("| Model | Status | AI | MPS | RSS | EditCons | Composite |")
343
+ lines.append("|-------|--------|---:|----:|----:|--------:|----------:|")
344
+ for cand in candidates:
345
+ ai = "—" if cand.ai_score is None else f"{cand.ai_score:.1f}"
346
+ mps = "—" if cand.mps is None else f"{cand.mps:.1f}"
347
+ rss = "—" if cand.rss is None else f"{cand.rss:.1f}"
348
+ edit = "—" if cand.edit_cons is None else f"{cand.edit_cons:.1f}"
349
+ comp = "—" if cand.composite is None else f"{cand.composite:.2f}"
350
+ lines.append(f"| {cand.model} | {cand.status} | {ai} | {mps} | {rss} | {edit} | {comp} |")
351
+ lines.append("")
352
+ if winner:
353
+ lines.append(f"**Winner:** `{winner.model}` — composite {winner.composite:.2f}")
354
+ else:
355
+ lines.append("**Winner:** none (no candidate scored successfully)")
356
+ lines.append("")
357
+ if any(cand.notes for cand in candidates):
358
+ lines.append("")
359
+ lines.append("## Notes")
360
+ for cand in candidates:
361
+ for note in cand.notes:
362
+ lines.append(f"- **{cand.model}**: {note}")
363
+ return "\n".join(lines) + "\n"
364
+
365
+
366
+ def main(argv: Optional[list[str]] = None) -> int:
367
+ parser = argparse.ArgumentParser(description="patina-max composite winner reselection.")
368
+ parser.add_argument("run_dir", type=Path, help="path to a patina-max run dir")
369
+ parser.add_argument(
370
+ "--weights",
371
+ type=str,
372
+ default=None,
373
+ help="override weights, comma-separated (e.g. ai=0.4,rss=0.3)",
374
+ )
375
+ parser.add_argument(
376
+ "--config",
377
+ type=Path,
378
+ default=Path(__file__).resolve().parents[1] / ".patina.default.yaml",
379
+ help="patina config to read composite-weights from",
380
+ )
381
+ args = parser.parse_args(argv)
382
+
383
+ run_dir: Path = args.run_dir.resolve()
384
+ if not run_dir.is_dir():
385
+ print(f"error: not a directory: {run_dir}", file=sys.stderr)
386
+ return 2
387
+
388
+ input_path = run_dir / "input.md"
389
+ if not input_path.exists():
390
+ print(f"error: missing required file: {input_path}", file=sys.stderr)
391
+ return 2
392
+ baseline = read_candidate_text(input_path)
393
+
394
+ meta_path = run_dir / "meta.md"
395
+ meta_info: dict[str, dict[str, str]] = {}
396
+ if meta_path.exists():
397
+ meta_info = parse_meta_candidates(meta_path.read_text(encoding="utf-8"))
398
+ else:
399
+ print(f"warning: missing meta.md at {meta_path}; AI/MPS will be marked unknown", file=sys.stderr)
400
+
401
+ weights = resolve_weights(args.config, args.weights)
402
+
403
+ candidates: list[Candidate] = []
404
+ for model in CANDIDATE_MODELS:
405
+ path = run_dir / f"{model}.md"
406
+ if not path.exists():
407
+ candidates.append(Candidate(model=model, text="", status="missing"))
408
+ continue
409
+ text = read_candidate_text(path)
410
+ info = meta_info.get(model, {})
411
+ cand = Candidate(
412
+ model=model,
413
+ text=text,
414
+ status=info.get("status", "unknown"),
415
+ ai_score=parse_metric(info.get("ai_score") or info.get("ai_score_instructional") or info.get("ai_score_technical")),
416
+ mps=parse_metric(info.get("mps")),
417
+ )
418
+ if cand.status == "success" and cand.text.strip():
419
+ cand.rss = register_stability(baseline, cand.text)
420
+ cand.edit_cons = edit_conservativeness(baseline, cand.text)
421
+ else:
422
+ cand.notes.append("skipping deterministic metrics (status not success or empty text)")
423
+ cand.composite = composite_score(cand, weights)
424
+ if cand.status == "success" and cand.composite is None:
425
+ cand.notes.append("composite undefined — at least one of AI/MPS could not be parsed from meta.md")
426
+ candidates.append(cand)
427
+
428
+ scored = [c for c in candidates if c.composite is not None]
429
+ winner: Optional[Candidate] = max(scored, key=lambda c: c.composite) if scored else None
430
+
431
+ composite_path = run_dir / "composite.md"
432
+ composite_path.write_text(
433
+ render_composite_md(run_dir, weights, candidates, winner),
434
+ encoding="utf-8",
435
+ )
436
+
437
+ winner_path = run_dir / "winner.md"
438
+ if winner is not None:
439
+ winner_path.write_text(
440
+ f"---\nwinner_model: {winner.model}\ncomposite_score: {winner.composite:.2f}\n---\n\n{winner.text.lstrip()}",
441
+ encoding="utf-8",
442
+ )
443
+ else:
444
+ winner_path.write_text("# winner.md\n\nNo candidate scored successfully.\n", encoding="utf-8")
445
+
446
+ cwd = Path.cwd()
447
+ print(f"wrote {composite_path.relative_to(cwd) if composite_path.is_relative_to(cwd) else composite_path}")
448
+ print(f"wrote {winner_path.relative_to(cwd) if winner_path.is_relative_to(cwd) else winner_path}")
449
+ if winner:
450
+ print(f"winner: {winner.model} (composite {winner.composite:.2f})")
451
+ else:
452
+ print("winner: none")
453
+ return 0
454
+
455
+
456
+ if __name__ == "__main__":
457
+ sys.exit(main())
@@ -0,0 +1,89 @@
1
+ ---
2
+ pack: en-communication
3
+ language: en
4
+ name: Communication Patterns
5
+ version: 1.1.0
6
+ patterns: 4
7
+ ---
8
+
9
+ # Communication Patterns
10
+
11
+ ### 19. Collaborative Communication Artifacts
12
+
13
+ **Watch words:** I hope this helps!, Let me know if you need, Feel free to ask, Happy to help, Don't hesitate to reach out, I'd be glad to, Is there anything else, Hope that clarifies things, Let me know if you'd like me to
14
+
15
+ **Fire condition:** Any chatbot-style conversational phrase appears in content that is not a live interactive conversation.
16
+
17
+ **Exclusion:** Acceptable in transcripts of actual live chat sessions, intentional UI microcopy for chatbot interfaces, or dialogue being quoted and analyzed.
18
+
19
+ **Problem:** AI includes chatbot conversational phrases in written content. These are appropriate in a live chat but not in articles, reports, or documentation.
20
+
21
+ **Semantic Risk:** LOW
22
+
23
+ **Before:**
24
+ > The French Revolution began in 1789, driven by fiscal crisis and food shortages. I hope this helps! Let me know if you need more details on any specific aspect of the revolution. Feel free to ask about the key figures involved.
25
+
26
+ **After:**
27
+ > The French Revolution began in 1789, driven by fiscal crisis and food shortages. The immediate trigger was the near-bankruptcy of the French state and a bread price spike that hit Paris hardest.
28
+
29
+ ---
30
+
31
+ ### 20. Knowledge-Cutoff Disclaimers
32
+
33
+ **Watch words:** as of my last update, I don't have access to real-time, my training data, I cannot verify current, as of my knowledge cutoff, I'm not able to browse, please verify this information, this may have changed since
34
+
35
+ **Fire condition:** Any AI self-reference or training-data caveat appears in editorial, journalistic, or analytical content.
36
+
37
+ **Exclusion:** Acceptable in technical documentation explicitly about AI systems, or in content that intentionally discloses AI generation (with a proper disclosure note). Also acceptable if the caveat is replaced with a dated, source-cited fact.
38
+
39
+ **Problem:** AI includes training-data caveats in content that should not reference AI limitations. These disclaimers break the fourth wall and remind readers the text was machine-generated.
40
+
41
+ **Semantic Risk:** MEDIUM
42
+ **Preservation Note:** The disclaimer sometimes carries a genuine factual caveat about data currency; removing it without replacing the claim with a dated source may leave an unverified assertion that the reader cannot assess.
43
+
44
+ **Before:**
45
+ > As of my last update in April 2024, the company had around 5,000 employees. I don't have access to real-time data, so please verify this information with current sources.
46
+
47
+ **After:**
48
+ > The company had about 5,000 employees as of its 2024 annual report.
49
+
50
+ ---
51
+
52
+ ### 21. Sycophantic/Servile Tone
53
+
54
+ **Watch words:** Great question!, That's an excellent point, You've raised a fascinating, What a thoughtful, That's a really interesting, Absolutely!, You're absolutely right, What a great observation
55
+
56
+ **Fire condition:** Any flattering or servile opener appears before substantive content.
57
+
58
+ **Exclusion:** None — this pattern has no valid context in editorial, analytical, or journalistic writing. Even in conversational formats, drop the flattery and start with the answer.
59
+
60
+ **Problem:** AI flatters the reader or questioner before answering. This servile opener adds no information and signals AI generation.
61
+
62
+ **Semantic Risk:** LOW
63
+
64
+ **Before:**
65
+ > Great question! That's a really fascinating topic. You've raised an excellent point about the economic factors at play. Let me break this down for you.
66
+
67
+ **After:**
68
+ > The main economic factor is the gap between housing supply and demand. Building permits in the metro area dropped 18% last year while population grew 2.1%.
69
+
70
+ ---
71
+
72
+ ### 29. False Nuance (Retroactive Reframing)
73
+
74
+ **Watch words:** Actually, it's more nuanced than that, To be more precise, Well, it's not quite that simple, That said, the reality is more complex, More accurately, In fairness, it's more that, If we're being precise, Though to be fair
75
+
76
+ **Fire condition:** The text restates or lightly reframes the preceding claim under the guise of adding nuance, without introducing new information, evidence, or a genuinely different perspective.
77
+
78
+ **Exclusion:** Acceptable when the reframe introduces a substantive correction, cites new evidence, or genuinely shifts the analytical frame in a way that changes the conclusion. Also acceptable in dialogue transcripts where the speaker is visibly self-correcting with new data.
79
+
80
+ **Problem:** AI hedges its own statements by immediately re-qualifying them in a way that sounds thoughtful but adds nothing. The "nuance" is cosmetic — the second sentence says the same thing as the first in slightly different words. This creates an illusion of depth while padding the text.
81
+
82
+ **Semantic Risk:** HIGH
83
+ **Preservation Note:** Removing the reframing clause may delete a genuine qualification or exception; verify that the retained primary claim accurately stands alone before removing the "nuance" sentence.
84
+
85
+ **Before:**
86
+ > Remote work increases productivity. Actually, it's more nuanced than that — remote work can enhance productivity in certain contexts while presenting challenges in others, and the net effect depends on organizational culture and individual work styles.
87
+
88
+ **After:**
89
+ > Remote work increases productivity for focused solo tasks — a Stanford study found a 13% gain for call center workers. It hurts spontaneous collaboration, though: Microsoft's 2021 internal data showed cross-team communication dropped 25% after going fully remote.
@@ -0,0 +1,133 @@
1
+ ---
2
+ pack: en-content
3
+ language: en
4
+ name: Content Patterns
5
+ version: 1.0.0
6
+ patterns: 6
7
+ ---
8
+
9
+ # Content Patterns
10
+
11
+ ### 1. Undue Emphasis on Significance
12
+
13
+ **Watch words:** significant milestone, pivotal moment, groundbreaking, transformative, paradigm shift, revolutionary, game-changing, unprecedented, landmark achievement, watershed moment, trailblazing, monumental
14
+
15
+ **Fire condition:** 2+ emphasis words appear in the same paragraph, or a single word like "revolutionary" or "groundbreaking" applied to an ordinary product or event.
16
+
17
+ **Exclusion:** Genuine historical events of large scale (first moon landing, eradication of a disease) where the adjective is proportionate. Use judgment on actual impact.
18
+
19
+ **Semantic Risk:** HIGH
20
+ **Preservation Note:** Removing emphasis words may delete the author's actual claim about significance; over-correction can convert a genuinely notable event into an unremarkable one.
21
+
22
+ **Problem:** AI inflates the importance of ordinary topics. Everything becomes a "significant milestone" or a "paradigm shift," regardless of actual impact.
23
+
24
+ **Before:**
25
+ > The company's new mobile app represents a groundbreaking paradigm shift in how users interact with grocery delivery services. This transformative, game-changing platform marks an unprecedented milestone in the retail industry.
26
+
27
+ **After:**
28
+ > The company launched a grocery delivery app. It lets users schedule same-day deliveries and track orders in real time. Downloads hit 2 million in the first month.
29
+
30
+ ---
31
+
32
+ ### 2. Undue Emphasis on Notability/Media
33
+
34
+ **Watch words:** garnered significant attention, widely recognized, has been featured in, attracted widespread interest, gained international acclaim, made headlines, captured the imagination of, has been praised by critics and audiences alike
35
+
36
+ **Fire condition:** A claim of broad attention, coverage, or acclaim appears without a named publication, outlet, or specific figure.
37
+
38
+ **Exclusion:** Statements like "widely used" backed by a rough scale ("used in 50 countries", "over 10 million installs") — specificity makes them acceptable even without named sources.
39
+
40
+ **Semantic Risk:** HIGH
41
+ **Preservation Note:** Correcting unsourced attention claims may remove a core assertion about recognition or impact that the author intended as a factual claim.
42
+
43
+ **Problem:** AI claims broad media coverage or public attention without citing specific sources or evidence.
44
+
45
+ **Before:**
46
+ > Her artwork has garnered significant attention from critics and audiences alike, and has been widely recognized as a defining voice of her generation. Her exhibitions have attracted widespread interest across the globe.
47
+
48
+ **After:**
49
+ > The New York Times reviewed her 2023 exhibition, calling her use of recycled materials "quietly radical." The show sold out its three-week run at the Whitechapel Gallery.
50
+
51
+ ---
52
+
53
+ ### 3. Superficial -ing Analyses
54
+
55
+ **Watch words:** showcasing, highlighting, underscoring, demonstrating, illustrating, reflecting, signaling, exemplifying, reinforcing, embodying, encapsulating
56
+
57
+ **Fire condition:** 3+ present-participle phrases chained in a single sentence or consecutive clauses with no concrete causal explanation.
58
+
59
+ **Exclusion:** A single well-placed participle for genuine causal or temporal connection ("the policy increased costs, pushing firms to cut staff") is acceptable and not this pattern.
60
+
61
+ **Semantic Risk:** HIGH
62
+ **Preservation Note:** Replacing participle chains with concrete explanation restructures the argument; if the causal relationship is not well understood, the correction may introduce a claim the original did not make.
63
+
64
+ **Problem:** AI uses present participle chains as filler analysis. Instead of explaining *why* something matters, it strings together "-ing" words that gesture at significance without saying anything concrete.
65
+
66
+ **Before:**
67
+ > The festival brings together artists from 30 countries, showcasing the diversity of contemporary dance, highlighting the importance of cross-cultural dialogue, and underscoring the role of the arts in fostering global understanding.
68
+
69
+ **After:**
70
+ > The festival brings together artists from 30 countries. This year, a butoh troupe from Tokyo collaborated with a hip-hop crew from Lagos — a pairing that would not have happened without the festival's residency program.
71
+
72
+ ---
73
+
74
+ ### 4. Promotional Language
75
+
76
+ **Watch words:** stunning, breathtaking, world-class, gem of, hidden treasure, crown jewel, vibrant, nestled in, boasts, a must-visit, unparalleled, exquisite, awe-inspiring, picturesque
77
+
78
+ **Fire condition:** 2+ promotional adjectives modifying the same subject, or a single strong superlative ("world-class", "breathtaking", "must-visit") used as descriptive prose rather than quoted marketing copy.
79
+
80
+ **Exclusion:** Direct quotations from marketing or promotional materials being analyzed — the promotional language belongs to the source, not the author.
81
+
82
+ **Semantic Risk:** MEDIUM
83
+ **Preservation Note:** Replacing promotional adjectives with neutral description may remove genuine qualitative assessments the author intended as their own voice or evaluation.
84
+
85
+ **Problem:** AI uses tourism-brochure language instead of neutral description, especially when writing about places, food, or cultural events.
86
+
87
+ **Before:**
88
+ > Nestled in the rolling hills of Tuscany, this stunning village boasts breathtaking views, world-class cuisine, and an exquisite charm that makes it a must-visit hidden gem for any discerning traveler.
89
+
90
+ **After:**
91
+ > The village sits on a hill about 40 minutes south of Florence. It has one restaurant, a weekly market on Thursdays, and a 14th-century church with frescoes that are slowly being restored.
92
+
93
+ ---
94
+
95
+ ### 5. Vague Attributions
96
+
97
+ **Watch words:** experts say, many believe, it is widely accepted, studies show, research indicates, critics argue, according to sources, observers note, analysts predict, some suggest, it is generally agreed
98
+
99
+ **Fire condition:** Any claim of authority appears with an unspecified source rather than a named one (person, institution, publication, or study with date).
100
+
101
+ **Exclusion:** Well-established consensus facts with no reasonable controversy ("doctors say smoking causes lung cancer") — use judgment on whether a named source is genuinely necessary.
102
+
103
+ **Semantic Risk:** HIGH
104
+ **Preservation Note:** Removing vague attributions without replacement deletes the author's sourcing claim; correction must substitute a real source or remove the claim entirely, which changes the text's evidentiary basis.
105
+
106
+ **Problem:** AI cites unnamed "experts" and "studies" instead of specific sources. This creates an illusion of authority without any verifiable backing.
107
+
108
+ **Before:**
109
+ > Experts say that remote work is here to stay. Studies show that productivity increases when employees work from home, and many believe this trend will reshape the commercial real estate market.
110
+
111
+ **After:**
112
+ > A 2023 Stanford study by Nicholas Bloom found that hybrid workers were 3% more productive than full-time office workers. Kastle Systems data shows U.S. office occupancy has stabilized at about 50% of pre-pandemic levels.
113
+
114
+ ---
115
+
116
+ ### 6. Formulaic "Challenges and Prospects"
117
+
118
+ **Watch words:** despite these challenges, remains to be seen, poised for growth, at a crossroads, on the cusp of, only time will tell, the road ahead, faces significant hurdles but, with continued effort, looking forward
119
+
120
+ **Fire condition:** A paragraph or conclusion contains both a generic challenge phrase AND a generic optimism phrase — the classic two-step pattern of acknowledging problems then pivoting to vague hope.
121
+
122
+ **Exclusion:** Genuine uncertainty expressed with specific caveats ("FDA approval takes 14 months; if denied by Q3, we shift to EU trials") — precision makes it acceptable. Only trigger when both poles are vague.
123
+
124
+ **Semantic Risk:** HIGH
125
+ **Preservation Note:** Replacing the challenge-prospect formula restructures the argument's conclusion; the corrected version must not omit actual challenges or prospects the author named, even vaguely.
126
+
127
+ **Problem:** AI wraps up with a generic challenges-then-optimism formula: acknowledge problems, then pivot to vague hope. This pattern appears at the end of almost every AI-generated article or essay.
128
+
129
+ **Before:**
130
+ > Despite these challenges, the industry remains poised for significant growth. While it remains to be seen how regulations will evolve, the sector stands at a crossroads, and with continued innovation and collaboration, a bright future lies ahead.
131
+
132
+ **After:**
133
+ > The biggest obstacle is the FDA approval timeline — the average wait is 14 months. Two of the five pending applications were filed before 2022 and still have no decision date. The company says it will shift trials to the EU if U.S. approval is not granted by Q3.