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,207 @@
1
+ import { callLLM as defaultCallLLM, callLLMMultiple } from './api.js';
2
+ import { scoreText, scoreMPS } from './scoring.js';
3
+ import { createLogger } from './logger.js';
4
+
5
+ const DEFAULT_WALL_CLOCK_BUDGET_MS = 300_000;
6
+
7
+ export async function runMaxMode({
8
+ prompt,
9
+ sourceText,
10
+ models,
11
+ apiKey,
12
+ baseURL,
13
+ config,
14
+ patterns,
15
+ maxConcurrency,
16
+ wallClockBudgetMs = DEFAULT_WALL_CLOCK_BUDGET_MS,
17
+ callLLM = defaultCallLLM,
18
+ now = () => Date.now(),
19
+ sleep,
20
+ callLLMMultipleImpl = callLLMMultiple,
21
+ scoreTextImpl = scoreText,
22
+ scoreMPSImpl = scoreMPS,
23
+ signal,
24
+ logger = createLogger(),
25
+ }) {
26
+ logger.info('max.dispatch', {
27
+ message: `[patina-max] Dispatching to ${models.length} models: ${models.join(', ')}`,
28
+ });
29
+
30
+ const controller = new AbortController();
31
+ const abortFromCaller = () => controller.abort();
32
+ let cleanupCallerSignal = () => {};
33
+ if (signal) {
34
+ if (signal.aborted) {
35
+ controller.abort();
36
+ } else {
37
+ signal.addEventListener('abort', abortFromCaller, { once: true });
38
+ cleanupCallerSignal = () => signal.removeEventListener('abort', abortFromCaller);
39
+ }
40
+ }
41
+ const deadline = now() + wallClockBudgetMs;
42
+ const progressStartedAt = now();
43
+ const modelStatus = new Map(models.map((model) => [model, '...']));
44
+ const modelStartedAt = new Map();
45
+ let timedOut = false;
46
+ const timeout = setTimeout(() => {
47
+ timedOut = true;
48
+ controller.abort();
49
+ logger.warn('max.timeout', { message: '[patina-max] MAX wall-clock timeout reached; returning partial results' });
50
+ }, wallClockBudgetMs);
51
+
52
+ const renderProgress = () => {
53
+ const statuses = models.map((model) => `${model} ${modelStatus.get(model) || '...'}`).join(' ');
54
+ const elapsedSeconds = Math.max(0, Math.round((now() - progressStartedAt) / 1000));
55
+ logger.progress('max.progress', {
56
+ message: `[patina-max] ${statuses} (${elapsedSeconds}s)`,
57
+ elapsed_ms: Math.max(0, now() - progressStartedAt),
58
+ });
59
+ };
60
+
61
+ const candidates = [];
62
+ try {
63
+ const results = await callLLMMultipleImpl({
64
+ prompt,
65
+ models,
66
+ apiKey,
67
+ baseURL,
68
+ maxConcurrency,
69
+ deadline,
70
+ signal: controller.signal,
71
+ callLLM,
72
+ now,
73
+ sleep,
74
+ onStart: (model) => {
75
+ modelStartedAt.set(model, now());
76
+ modelStatus.set(model, '...');
77
+ renderProgress();
78
+ },
79
+ onComplete: (model, ok) => {
80
+ const latencyMs = modelStartedAt.has(model) ? Math.max(0, now() - modelStartedAt.get(model)) : undefined;
81
+ modelStatus.set(model, ok ? '✓' : '✗');
82
+ logger.progress('max.model_complete', {
83
+ message: formatMaxProgress(models, modelStatus, progressStartedAt, now),
84
+ model,
85
+ latency_ms: latencyMs,
86
+ });
87
+ },
88
+ });
89
+
90
+ for (const r of results) {
91
+ if (!r.ok) {
92
+ candidates.push({ model: r.model, ok: false, error: r.error });
93
+ continue;
94
+ }
95
+
96
+ let aiScoreResult = null;
97
+ let mpsResult = null;
98
+
99
+ if (!timedOut) {
100
+ aiScoreResult = await scoreTextImpl({
101
+ text: r.result,
102
+ config,
103
+ patterns,
104
+ apiKey,
105
+ baseURL,
106
+ model: r.model,
107
+ deadline,
108
+ signal: controller.signal,
109
+ callLLM,
110
+ logger,
111
+ now,
112
+ sleep,
113
+ });
114
+ }
115
+
116
+ if (!timedOut) {
117
+ mpsResult = await scoreMPSImpl({
118
+ original: sourceText,
119
+ rewritten: r.result,
120
+ apiKey,
121
+ baseURL,
122
+ model: r.model,
123
+ deadline,
124
+ signal: controller.signal,
125
+ callLLM,
126
+ logger,
127
+ now,
128
+ sleep,
129
+ });
130
+ }
131
+
132
+ candidates.push({
133
+ model: r.model,
134
+ ok: true,
135
+ result: r.result,
136
+ aiScore: aiScoreResult?.overall ?? null,
137
+ mps: mpsResult?.mps ?? null,
138
+ });
139
+
140
+ if (timedOut) break;
141
+ }
142
+ } finally {
143
+ clearTimeout(timeout);
144
+ cleanupCallerSignal();
145
+ logger.closeProgress();
146
+ }
147
+
148
+ const { candidate: best, fallback } = selectBest(candidates, {
149
+ log: (message) => logger.warn('max.selection_tie', { message }),
150
+ });
151
+ const allFailed = best === null;
152
+
153
+ return {
154
+ type: 'max-mode',
155
+ candidates,
156
+ best,
157
+ allFailed,
158
+ mpsFallback: fallback,
159
+ timedOut,
160
+ };
161
+ }
162
+
163
+ export function selectBest(
164
+ candidates,
165
+ { log = (message) => createLogger().warn('max.selection_tie', { message }) } = {}
166
+ ) {
167
+ const valid = candidates.filter((c) => c.ok && c.aiScore !== null);
168
+
169
+ if (valid.length === 0) {
170
+ return { candidate: null, fallback: false };
171
+ }
172
+
173
+ const passingMps = valid.filter((c) => (c.mps ?? 0) >= 70);
174
+
175
+ if (passingMps.length > 0) {
176
+ const best = passingMps.reduce((best, current) =>
177
+ // Strict comparison preserves --models config order when AI scores tie.
178
+ (current.aiScore < best.aiScore) ? current : best
179
+ );
180
+
181
+ if (passingMps.some((c) => c !== best && c.aiScore === best.aiScore)) {
182
+ log(`[patina-max] Tie on AI score — picked ${best.model} by config order`);
183
+ }
184
+
185
+ return { candidate: best, fallback: false };
186
+ }
187
+
188
+ const best = valid.reduce((best, current) => {
189
+ const bestMps = best.mps ?? -1;
190
+ const currentMps = current.mps ?? -1;
191
+ // Strict comparison preserves --models config order when MPS scores tie.
192
+ return currentMps > bestMps ? current : best;
193
+ });
194
+
195
+ const bestMps = best.mps ?? -1;
196
+ if (valid.some((c) => c !== best && (c.mps ?? -1) === bestMps)) {
197
+ log(`[patina-max] Tie on MPS — picked ${best.model} by config order`);
198
+ }
199
+
200
+ return { candidate: best, fallback: true };
201
+ }
202
+
203
+ function formatMaxProgress(models, modelStatus, startedAt, now) {
204
+ const statuses = models.map((model) => `${model} ${modelStatus.get(model) || '...'}`).join(' ');
205
+ const elapsedSeconds = Math.max(0, Math.round((now() - startedAt) / 1000));
206
+ return `[patina-max] ${statuses} (${elapsedSeconds}s)`;
207
+ }
@@ -0,0 +1,233 @@
1
+ import { callLLM as defaultCallLLM } from './api.js';
2
+ import { scoreText, scoreMPS, scoreFidelity, combinedScore } from './scoring.js';
3
+ import { buildPrompt } from './prompt-builder.js';
4
+ import { createLogger } from './logger.js';
5
+
6
+ export async function runOuroboros({
7
+ config,
8
+ patterns,
9
+ profile,
10
+ voice,
11
+ voiceSample,
12
+ scoring,
13
+ text,
14
+ apiKey,
15
+ baseURL,
16
+ model,
17
+ callLLM = defaultCallLLM,
18
+ now,
19
+ sleep,
20
+ signal,
21
+ logger = createLogger(),
22
+ }) {
23
+ const ouroborosConfig = config.ouroboros || {};
24
+ const targetScore = ouroborosConfig['target-score'] ?? 30;
25
+ const maxIterations = ouroborosConfig['max-iterations'] ?? 3;
26
+ const plateauThreshold = ouroborosConfig['plateau-threshold'] ?? 10;
27
+ const fidelityFloor = ouroborosConfig['fidelity-floor'] ?? 70;
28
+ const mpsFloor = ouroborosConfig['mps-floor'] ?? 70;
29
+
30
+ const iterationLog = [];
31
+
32
+ const initialScoreResult = await scoreText({
33
+ text,
34
+ config,
35
+ patterns,
36
+ apiKey,
37
+ baseURL,
38
+ model,
39
+ callLLM,
40
+ now,
41
+ sleep,
42
+ signal,
43
+ logger,
44
+ });
45
+
46
+ const initialScore = initialScoreResult?.overall ?? 100;
47
+
48
+ iterationLog.push({
49
+ iteration: 0,
50
+ before: null,
51
+ after: initialScore,
52
+ improvement: null,
53
+ reason: 'Initial',
54
+ });
55
+
56
+ if (initialScore <= targetScore) {
57
+ return {
58
+ finalText: text,
59
+ finalScore: initialScore,
60
+ iterations: 0,
61
+ reason: `Already at target (score: ${initialScore})`,
62
+ log: iterationLog,
63
+ };
64
+ }
65
+
66
+ let currentText = text;
67
+ let previousScore = initialScore;
68
+ let bestText = text;
69
+ let bestScore = initialScore;
70
+ // Original is identical to itself, so its fidelity is 100 by definition.
71
+ // This gives iteration 1 a valid combined baseline to detect regressions against.
72
+ let previousCombined = combinedScore({
73
+ aiLikeness: initialScore,
74
+ fidelity: 100,
75
+ profile: config.profile,
76
+ config,
77
+ deterministicScore: initialScoreResult?.deterministicScore,
78
+ });
79
+
80
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
81
+ const prompt = buildPrompt({
82
+ config,
83
+ patterns,
84
+ profile,
85
+ voice,
86
+ voiceSample,
87
+ scoring,
88
+ text: currentText,
89
+ mode: 'rewrite',
90
+ });
91
+
92
+ const iterationStartedAt = now ? now() : Date.now();
93
+ const humanized = await callLLM({
94
+ prompt,
95
+ apiKey,
96
+ baseURL,
97
+ model,
98
+ now,
99
+ sleep,
100
+ signal,
101
+ });
102
+
103
+ const scoreResult = await scoreText({
104
+ text: humanized,
105
+ config,
106
+ patterns,
107
+ apiKey,
108
+ baseURL,
109
+ model,
110
+ callLLM,
111
+ now,
112
+ sleep,
113
+ signal,
114
+ logger,
115
+ });
116
+
117
+ let currentScore = scoreResult?.overall ?? 100;
118
+ const delta = previousScore - currentScore;
119
+ const latencyMs = Math.max(0, (now ? now() : Date.now()) - iterationStartedAt);
120
+ logger.info('ouroboros.iteration', {
121
+ message: `[ouroboros] iter ${iteration}/${maxIterations} score ${previousScore} → ${currentScore} (${formatElapsed(latencyMs)})`,
122
+ model,
123
+ latency_ms: latencyMs,
124
+ });
125
+
126
+ const [mpsResult, fidelityResult] = await Promise.all([
127
+ scoreMPS({
128
+ original: text,
129
+ rewritten: humanized,
130
+ apiKey,
131
+ baseURL,
132
+ model,
133
+ callLLM,
134
+ now,
135
+ sleep,
136
+ signal,
137
+ logger,
138
+ }),
139
+ scoreFidelity({
140
+ original: text,
141
+ rewritten: humanized,
142
+ apiKey,
143
+ baseURL,
144
+ model,
145
+ callLLM,
146
+ now,
147
+ sleep,
148
+ signal,
149
+ logger,
150
+ }),
151
+ ]);
152
+
153
+ const mps = mpsResult?.mps ?? 100;
154
+ const fidelity = fidelityResult?.fidelity ?? 100;
155
+ const combined = combinedScore({
156
+ aiLikeness: currentScore,
157
+ fidelity,
158
+ profile: config.profile,
159
+ config,
160
+ deterministicScore: scoreResult?.deterministicScore,
161
+ });
162
+ const combinedDelta = previousCombined - combined;
163
+
164
+ let reason = '';
165
+ let shouldStop = false;
166
+ let shouldRollback = false;
167
+
168
+ if (currentScore <= targetScore) {
169
+ reason = 'Target met';
170
+ shouldStop = true;
171
+ } else if (combinedDelta < 0) {
172
+ reason = `Regression (combined ${previousCombined} → ${combined})`;
173
+ shouldStop = true;
174
+ shouldRollback = true;
175
+ } else if (fidelity < fidelityFloor) {
176
+ reason = 'Fidelity floor violation';
177
+ shouldStop = true;
178
+ shouldRollback = true;
179
+ } else if (mps < mpsFloor) {
180
+ reason = 'MPS floor violation';
181
+ shouldStop = true;
182
+ shouldRollback = true;
183
+ } else if (delta <= plateauThreshold) {
184
+ reason = 'Plateau';
185
+ shouldStop = true;
186
+ } else if (iteration >= maxIterations) {
187
+ reason = 'Max iterations';
188
+ shouldStop = true;
189
+ }
190
+
191
+ iterationLog.push({
192
+ iteration,
193
+ before: previousScore,
194
+ after: currentScore,
195
+ improvement: delta,
196
+ fidelity,
197
+ mps,
198
+ combined,
199
+ combinedDelta,
200
+ reason: shouldStop ? reason : '',
201
+ });
202
+
203
+ if (shouldRollback) {
204
+ currentText = bestText;
205
+ currentScore = bestScore;
206
+ } else {
207
+ currentText = humanized;
208
+ previousScore = currentScore;
209
+ previousCombined = combined;
210
+
211
+ if (currentScore < bestScore) {
212
+ bestText = humanized;
213
+ bestScore = currentScore;
214
+ }
215
+ }
216
+
217
+ if (shouldStop) {
218
+ break;
219
+ }
220
+ }
221
+
222
+ return {
223
+ finalText: currentText,
224
+ finalScore: bestScore,
225
+ iterations: iterationLog.length - 1,
226
+ reason: iterationLog[iterationLog.length - 1]?.reason || 'Completed',
227
+ log: iterationLog,
228
+ };
229
+ }
230
+
231
+ function formatElapsed(ms) {
232
+ return `${Math.round(ms / 100) / 10}s`;
233
+ }