@trazum/core 1.8.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 (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/advisories.d.ts +23 -0
  4. package/dist/advisories.d.ts.map +1 -0
  5. package/dist/advisories.js +376 -0
  6. package/dist/advisories.js.map +1 -0
  7. package/dist/aws-sigv4.d.ts +88 -0
  8. package/dist/aws-sigv4.d.ts.map +1 -0
  9. package/dist/aws-sigv4.js +117 -0
  10. package/dist/aws-sigv4.js.map +1 -0
  11. package/dist/baseline.d.ts +171 -0
  12. package/dist/baseline.d.ts.map +1 -0
  13. package/dist/baseline.js +273 -0
  14. package/dist/baseline.js.map +1 -0
  15. package/dist/cache.d.ts +26 -0
  16. package/dist/cache.d.ts.map +1 -0
  17. package/dist/cache.js +28 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/changes.d.ts +29 -0
  20. package/dist/changes.d.ts.map +1 -0
  21. package/dist/changes.js +142 -0
  22. package/dist/changes.js.map +1 -0
  23. package/dist/compare.d.ts +65 -0
  24. package/dist/compare.d.ts.map +1 -0
  25. package/dist/compare.js +58 -0
  26. package/dist/compare.js.map +1 -0
  27. package/dist/config-schema.d.ts +118 -0
  28. package/dist/config-schema.d.ts.map +1 -0
  29. package/dist/config-schema.js +315 -0
  30. package/dist/config-schema.js.map +1 -0
  31. package/dist/config.d.ts +47 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +112 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/detect.d.ts +70 -0
  36. package/dist/detect.d.ts.map +1 -0
  37. package/dist/detect.js +228 -0
  38. package/dist/detect.js.map +1 -0
  39. package/dist/evaluate.d.ts +98 -0
  40. package/dist/evaluate.d.ts.map +1 -0
  41. package/dist/evaluate.js +110 -0
  42. package/dist/evaluate.js.map +1 -0
  43. package/dist/extract.d.ts +81 -0
  44. package/dist/extract.d.ts.map +1 -0
  45. package/dist/extract.js +280 -0
  46. package/dist/extract.js.map +1 -0
  47. package/dist/gcp-auth.d.ts +58 -0
  48. package/dist/gcp-auth.d.ts.map +1 -0
  49. package/dist/gcp-auth.js +113 -0
  50. package/dist/gcp-auth.js.map +1 -0
  51. package/dist/glob.d.ts +49 -0
  52. package/dist/glob.d.ts.map +1 -0
  53. package/dist/glob.js +154 -0
  54. package/dist/glob.js.map +1 -0
  55. package/dist/host.d.ts +30 -0
  56. package/dist/host.d.ts.map +1 -0
  57. package/dist/host.js +69 -0
  58. package/dist/host.js.map +1 -0
  59. package/dist/i18n/en.d.ts +4 -0
  60. package/dist/i18n/en.d.ts.map +1 -0
  61. package/dist/i18n/en.js +168 -0
  62. package/dist/i18n/en.js.map +1 -0
  63. package/dist/i18n/es.d.ts +4 -0
  64. package/dist/i18n/es.d.ts.map +1 -0
  65. package/dist/i18n/es.js +168 -0
  66. package/dist/i18n/es.js.map +1 -0
  67. package/dist/i18n/index.d.ts +36 -0
  68. package/dist/i18n/index.d.ts.map +1 -0
  69. package/dist/i18n/index.js +50 -0
  70. package/dist/i18n/index.js.map +1 -0
  71. package/dist/i18n/types.d.ts +180 -0
  72. package/dist/i18n/types.d.ts.map +1 -0
  73. package/dist/i18n/types.js +11 -0
  74. package/dist/i18n/types.js.map +1 -0
  75. package/dist/index.d.ts +66 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +69 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/llm.d.ts +226 -0
  80. package/dist/llm.d.ts.map +1 -0
  81. package/dist/llm.js +485 -0
  82. package/dist/llm.js.map +1 -0
  83. package/dist/nearest.d.ts +20 -0
  84. package/dist/nearest.d.ts.map +1 -0
  85. package/dist/nearest.js +54 -0
  86. package/dist/nearest.js.map +1 -0
  87. package/dist/net.d.ts +90 -0
  88. package/dist/net.d.ts.map +1 -0
  89. package/dist/net.js +203 -0
  90. package/dist/net.js.map +1 -0
  91. package/dist/node.d.ts +32 -0
  92. package/dist/node.d.ts.map +1 -0
  93. package/dist/node.js +33 -0
  94. package/dist/node.js.map +1 -0
  95. package/dist/openrouter.d.ts +25 -0
  96. package/dist/openrouter.d.ts.map +1 -0
  97. package/dist/openrouter.js +72 -0
  98. package/dist/openrouter.js.map +1 -0
  99. package/dist/optimize.d.ts +38 -0
  100. package/dist/optimize.d.ts.map +1 -0
  101. package/dist/optimize.js +183 -0
  102. package/dist/optimize.js.map +1 -0
  103. package/dist/otlp.d.ts +91 -0
  104. package/dist/otlp.d.ts.map +1 -0
  105. package/dist/otlp.js +102 -0
  106. package/dist/otlp.js.map +1 -0
  107. package/dist/phrases.d.ts +169 -0
  108. package/dist/phrases.d.ts.map +1 -0
  109. package/dist/phrases.js +939 -0
  110. package/dist/phrases.js.map +1 -0
  111. package/dist/pricing-overlay.d.ts +55 -0
  112. package/dist/pricing-overlay.d.ts.map +1 -0
  113. package/dist/pricing-overlay.js +241 -0
  114. package/dist/pricing-overlay.js.map +1 -0
  115. package/dist/pricing.d.ts +115 -0
  116. package/dist/pricing.d.ts.map +1 -0
  117. package/dist/pricing.js +400 -0
  118. package/dist/pricing.js.map +1 -0
  119. package/dist/profile.d.ts +71 -0
  120. package/dist/profile.d.ts.map +1 -0
  121. package/dist/profile.js +55 -0
  122. package/dist/profile.js.map +1 -0
  123. package/dist/promptfoo.d.ts +58 -0
  124. package/dist/promptfoo.d.ts.map +1 -0
  125. package/dist/promptfoo.js +149 -0
  126. package/dist/promptfoo.js.map +1 -0
  127. package/dist/prune.d.ts +91 -0
  128. package/dist/prune.d.ts.map +1 -0
  129. package/dist/prune.js +110 -0
  130. package/dist/prune.js.map +1 -0
  131. package/dist/reorder.d.ts +82 -0
  132. package/dist/reorder.d.ts.map +1 -0
  133. package/dist/reorder.js +215 -0
  134. package/dist/reorder.js.map +1 -0
  135. package/dist/review.d.ts +54 -0
  136. package/dist/review.d.ts.map +1 -0
  137. package/dist/review.js +131 -0
  138. package/dist/review.js.map +1 -0
  139. package/dist/rules.d.ts +5 -0
  140. package/dist/rules.d.ts.map +1 -0
  141. package/dist/rules.js +279 -0
  142. package/dist/rules.js.map +1 -0
  143. package/dist/savings.d.ts +36 -0
  144. package/dist/savings.d.ts.map +1 -0
  145. package/dist/savings.js +83 -0
  146. package/dist/savings.js.map +1 -0
  147. package/dist/segment.d.ts +8 -0
  148. package/dist/segment.d.ts.map +1 -0
  149. package/dist/segment.js +74 -0
  150. package/dist/segment.js.map +1 -0
  151. package/dist/shared-prefix.d.ts +63 -0
  152. package/dist/shared-prefix.d.ts.map +1 -0
  153. package/dist/shared-prefix.js +151 -0
  154. package/dist/shared-prefix.js.map +1 -0
  155. package/dist/similarity.d.ts +13 -0
  156. package/dist/similarity.d.ts.map +1 -0
  157. package/dist/similarity.js +30 -0
  158. package/dist/similarity.js.map +1 -0
  159. package/dist/structure.d.ts +144 -0
  160. package/dist/structure.d.ts.map +1 -0
  161. package/dist/structure.js +455 -0
  162. package/dist/structure.js.map +1 -0
  163. package/dist/suggest.d.ts +100 -0
  164. package/dist/suggest.d.ts.map +1 -0
  165. package/dist/suggest.js +151 -0
  166. package/dist/suggest.js.map +1 -0
  167. package/dist/tokenizer.d.ts +57 -0
  168. package/dist/tokenizer.d.ts.map +1 -0
  169. package/dist/tokenizer.js +157 -0
  170. package/dist/tokenizer.js.map +1 -0
  171. package/dist/types.d.ts +296 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +3 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/walk.d.ts +40 -0
  176. package/dist/walk.d.ts.map +1 -0
  177. package/dist/walk.js +95 -0
  178. package/dist/walk.js.map +1 -0
  179. package/package.json +56 -0
  180. package/src/advisories.ts +431 -0
  181. package/src/aws-sigv4.ts +174 -0
  182. package/src/baseline.ts +390 -0
  183. package/src/cache.ts +54 -0
  184. package/src/changes.ts +158 -0
  185. package/src/compare.ts +131 -0
  186. package/src/config-schema.ts +451 -0
  187. package/src/config.ts +161 -0
  188. package/src/detect.ts +312 -0
  189. package/src/evaluate.ts +188 -0
  190. package/src/extract.ts +336 -0
  191. package/src/gcp-auth.ts +166 -0
  192. package/src/glob.ts +160 -0
  193. package/src/host.ts +90 -0
  194. package/src/i18n/en.ts +236 -0
  195. package/src/i18n/es.ts +236 -0
  196. package/src/i18n/index.ts +68 -0
  197. package/src/i18n/types.ts +230 -0
  198. package/src/index.ts +228 -0
  199. package/src/llm.ts +708 -0
  200. package/src/nearest.ts +61 -0
  201. package/src/net.ts +233 -0
  202. package/src/node.ts +63 -0
  203. package/src/openrouter.ts +125 -0
  204. package/src/optimize.ts +228 -0
  205. package/src/otlp.ts +179 -0
  206. package/src/phrases.ts +1047 -0
  207. package/src/pricing-overlay.ts +319 -0
  208. package/src/pricing.ts +468 -0
  209. package/src/profile.ts +124 -0
  210. package/src/promptfoo.ts +213 -0
  211. package/src/prune.ts +211 -0
  212. package/src/reorder.ts +307 -0
  213. package/src/review.ts +180 -0
  214. package/src/rules.ts +324 -0
  215. package/src/savings.ts +121 -0
  216. package/src/segment.ts +106 -0
  217. package/src/shared-prefix.ts +198 -0
  218. package/src/similarity.ts +28 -0
  219. package/src/structure.ts +652 -0
  220. package/src/suggest.ts +254 -0
  221. package/src/tokenizer.ts +190 -0
  222. package/src/types.ts +323 -0
  223. package/src/walk.ts +117 -0
@@ -0,0 +1,652 @@
1
+ import { segment } from './segment.js';
2
+ import { OUTPUT_CUES } from './phrases.js';
3
+ import { jaccard, normalizeForCompare } from './similarity.js';
4
+ import type { ContradictionAxisId, ContradictionValueId } from './i18n/types.js';
5
+ import type { TokenCounter } from './types.js';
6
+
7
+ /**
8
+ * Structural analysis.
9
+ *
10
+ * Every rule in `rules.ts` matches a phrase. The waste this module looks for
11
+ * is a relationship between two places in the prompt — an instruction that
12
+ * contradicts another, an example that teaches what an earlier one already
13
+ * taught. No dictionary can see either, because neither is wrong on its own.
14
+ *
15
+ * Everything here is advisory. A contradiction has a right answer only the
16
+ * author knows, and an example that looks redundant may be demonstrating a
17
+ * boundary case on purpose. Trazum points; it does not cut.
18
+ */
19
+
20
+ // --------------------------------------------------------------------------
21
+ // Contradictory instructions
22
+ // --------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Sourced from the i18n contract rather than declared here, so adding an axis
26
+ * fails to compile until every catalogue can name it.
27
+ */
28
+ export type ContradictionAxis = ContradictionAxisId;
29
+
30
+ export interface ContradictionSide {
31
+ /** Which end of the axis this instruction sits on. */
32
+ value: ContradictionValueId;
33
+ /** The sentence it was found in, trimmed for display. */
34
+ snippet: string;
35
+ }
36
+
37
+ export interface Contradiction {
38
+ axis: ContradictionAxis;
39
+ a: ContradictionSide;
40
+ b: ContradictionSide;
41
+ }
42
+
43
+ interface AxisDefinition {
44
+ axis: ContradictionAxis;
45
+ values: ReadonlyArray<readonly [value: ContradictionValueId, pattern: RegExp]>;
46
+ }
47
+
48
+ /**
49
+ * Verbs that mark a sentence as being about the *response*.
50
+ *
51
+ * Requiring one is what keeps "translate this into English" from being read as
52
+ * "answer in English". Without it the language axis fires on any prompt that
53
+ * merely mentions a language, which is most translation prompts.
54
+ */
55
+ const RESPOND = String.raw`(?:respond|reply|answer|write|speak|output|responde|contesta|responder|escribe|redacta)`;
56
+
57
+ const AXES: readonly AxisDefinition[] = [
58
+ {
59
+ axis: 'response-language',
60
+ values: [
61
+ [
62
+ 'fixed-language',
63
+ new RegExp(
64
+ String.raw`\b${RESPOND}\b[^.!?\n]{0,40}?\bin\s+(?:english|spanish|french|german|italian|portuguese|ingl[ée]s|espa[nñ]ol|franc[ée]s|alem[áa]n)\b|\b${RESPOND}\b[^.!?\n]{0,40}?\ben\s+(?:ingl[ée]s|espa[nñ]ol|franc[ée]s|alem[áa]n)\b`,
65
+ 'i',
66
+ ),
67
+ ],
68
+ [
69
+ 'mirror-language',
70
+ new RegExp(
71
+ // The qualifier slot ("own", "native", "preferred") is what makes
72
+ // this survive real prompts: "the customer's own language" is at
73
+ // least as common as the bare form.
74
+ String.raw`\bin\s+the\s+(?:same\s+)?language\s+(?:of|as|the\s+\w+\s+(?:used|wrote|speaks))|\bin\s+(?:the\s+(?:user|customer|client)'?s?|their)\s+(?:own\s+|native\s+|preferred\s+)?language\b|\bmatch\s+the\s+language\b|\ben\s+(?:el\s+)?(?:mismo\s+)?idioma\s+(?:del?\s+|en\s+que\s+)?(?:usuario|cliente|consulta|mensaje|escrib)`,
75
+ 'i',
76
+ ),
77
+ ],
78
+ ],
79
+ },
80
+ {
81
+ axis: 'output-format',
82
+ values: [
83
+ [
84
+ 'format-json',
85
+ new RegExp(
86
+ String.raw`\b(?:${RESPOND}|return|format|devuelve|formatea)\b[^.!?\n]{0,40}?\b(?:as|in|with|using|only|en|como)\s+(?:valid\s+|solo\s+|válido\s+)?json\b|\bonly\s+json\b|\bsolo\s+json\b`,
87
+ 'i',
88
+ ),
89
+ ],
90
+ [
91
+ 'format-markdown',
92
+ new RegExp(
93
+ String.raw`\b(?:${RESPOND}|return|format|devuelve|formatea)\b[^.!?\n]{0,40}?\b(?:as|in|with|using|en|como)\s+markdown\b|\buse\s+markdown\b|\busa\s+markdown\b`,
94
+ 'i',
95
+ ),
96
+ ],
97
+ [
98
+ 'format-plain-text',
99
+ new RegExp(
100
+ String.raw`\bplain\s+text\b|\bno\s+markdown\b|\bwithout\s+markdown\b|\btexto\s+plano\b|\bsin\s+markdown\b`,
101
+ 'i',
102
+ ),
103
+ ],
104
+ ],
105
+ },
106
+ {
107
+ axis: 'response-length',
108
+ values: [
109
+ [
110
+ 'length-brief',
111
+ new RegExp(
112
+ String.raw`\b(?:be\s+(?:brief|concise|succinct|terse)|keep\s+it\s+(?:short|brief)|as\s+short\s+as\s+possible|in\s+(?:one|a\s+single)\s+sentence|s[ée]\s+(?:breve|conciso)|de\s+forma\s+(?:breve|concisa)|brevemente)\b`,
113
+ 'i',
114
+ ),
115
+ ],
116
+ [
117
+ 'length-detailed',
118
+ new RegExp(
119
+ String.raw`\b(?:be\s+(?:detailed|comprehensive|thorough|exhaustive)|in\s+(?:great\s+)?depth|as\s+much\s+detail\s+as\s+possible|s[ée]\s+(?:exhaustivo|detallado)|de\s+forma\s+(?:detallada|exhaustiva)|con\s+todo\s+detalle|detalladamente)\b`,
120
+ 'i',
121
+ ),
122
+ ],
123
+ ],
124
+ },
125
+ {
126
+ axis: 'reasoning-visibility',
127
+ values: [
128
+ [
129
+ 'reasoning-shown',
130
+ new RegExp(
131
+ String.raw`\b(?:explain\s+your\s+(?:reasoning|answer|thinking)|show\s+your\s+(?:work|reasoning|thinking)|think\s+step[\s-]by[\s-]step|justify\s+your\s+answer|explica\s+tu\s+razonamiento|razona\s+paso\s+a\s+paso|justifica\s+tu\s+respuesta)\b`,
132
+ 'i',
133
+ ),
134
+ ],
135
+ [
136
+ 'reasoning-hidden',
137
+ new RegExp(
138
+ String.raw`\b(?:no\s+(?:explanation|commentary|preamble)|without\s+(?:explanation|commentary)|do\s+not\s+explain|don'?t\s+explain|no\s+expliques|sin\s+(?:explicaciones|comentarios)|sin\s+preámbulo)\b`,
139
+ 'i',
140
+ ),
141
+ ],
142
+ ],
143
+ },
144
+ ];
145
+
146
+ /**
147
+ * Text the analysis is allowed to read.
148
+ *
149
+ * Protected segments are replaced by a space rather than kept: a JSON schema
150
+ * inside a code fence is the *specification* of the output format, not a
151
+ * second instruction about it, and reading it as one would report a
152
+ * contradiction in every well-written prompt that shows its schema.
153
+ */
154
+ function analysableText(prompt: string): string {
155
+ return segment(prompt)
156
+ .map((s) => (s.kind === 'mutable' ? s.text : ' '))
157
+ .join('');
158
+ }
159
+
160
+ /** Splits into sentences, keeping line breaks as boundaries. */
161
+ function sentences(text: string): string[] {
162
+ return text
163
+ .split(/(?<=[.!?;:])\s+|\n+/)
164
+ .map((s) => s.trim())
165
+ .filter(Boolean);
166
+ }
167
+
168
+ function trimSnippet(sentence: string, max = 90): string {
169
+ const clean = sentence.replace(/\s+/g, ' ').trim();
170
+ return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
171
+ }
172
+
173
+ /**
174
+ * Finds instructions that pull in opposite directions on the same axis.
175
+ *
176
+ * Only the first occurrence of each value is kept, and an axis reports at most
177
+ * one contradiction: a prompt that says "be concise" four times and "be
178
+ * thorough" once has one problem, not four.
179
+ *
180
+ * Two matches inside the same sentence are ignored. "Be concise but complete"
181
+ * is a deliberate trade-off an author wrote on purpose; flagging it would
182
+ * train people to ignore the advisory.
183
+ */
184
+ export function findContradictions(prompt: string): Contradiction[] {
185
+ const found: Contradiction[] = [];
186
+ const lines = sentences(analysableText(prompt));
187
+
188
+ for (const { axis, values } of AXES) {
189
+ const hits = new Map<ContradictionValueId, { snippet: string; sentence: number }>();
190
+
191
+ lines.forEach((sentence, index) => {
192
+ for (const [value, pattern] of values) {
193
+ if (!hits.has(value) && pattern.test(sentence)) {
194
+ hits.set(value, { snippet: trimSnippet(sentence), sentence: index });
195
+ }
196
+ }
197
+ });
198
+
199
+ if (hits.size < 2) continue;
200
+
201
+ const [first, second] = [...hits.entries()];
202
+ if (!first || !second) continue;
203
+ if (first[1].sentence === second[1].sentence) continue;
204
+
205
+ found.push({
206
+ axis,
207
+ a: { value: first[0], snippet: first[1].snippet },
208
+ b: { value: second[0], snippet: second[1].snippet },
209
+ });
210
+ }
211
+
212
+ return found;
213
+ }
214
+
215
+ // --------------------------------------------------------------------------
216
+ // Redundant few-shot examples
217
+ // --------------------------------------------------------------------------
218
+
219
+ /**
220
+ * The two halves of an example label, as bounded fragments.
221
+ *
222
+ * Every quantifier here has an upper bound, and that is load-bearing rather
223
+ * than tidiness. The previous form ended `\s*\d*\s*[:.)]` — three adjacent
224
+ * unbounded quantifiers over overlapping character classes. On a line like
225
+ * `example` followed by 40 000 spaces and no terminator, the engine has to try
226
+ * every way of splitting that whitespace between the two `\s*` groups before
227
+ * it can fail: measured O(n²), 651 ms at 40 000 spaces, roughly a minute at
228
+ * the API's 400 KB cap — a denial of service in one request.
229
+ *
230
+ * Bounding turns the split count into a constant. A label reading
231
+ * `Example 12:` never needs more than a few spaces or six digits, so nothing
232
+ * real is lost.
233
+ *
234
+ * `[ \t]` rather than `\s` because these are matched per line, where `\s`
235
+ * matching newlines is wrong anyway.
236
+ */
237
+ const LABEL_PREFIX = String.raw`^[ \t]{0,16}(?:#{1,6}[ \t]{0,4})?(?:[-*][ \t]{0,4})?`;
238
+ const LABEL_SUFFIX = String.raw`[ \t]{0,4}\d{0,6}[ \t]{0,4}[:.)]`;
239
+
240
+ /**
241
+ * Header styles that open a new example, most explicit first.
242
+ *
243
+ * Tiered rather than combined because they nest: a prompt using "Example 1:"
244
+ * almost always puts "Input:"/"Output:" *inside* each example, so matching
245
+ * both at once would cut every example in half and compare the halves. The
246
+ * first tier that finds at least two blocks wins.
247
+ *
248
+ * Deliberately conservative overall: an unlabelled example is
249
+ * indistinguishable from ordinary prose, and guessing would make the advisory
250
+ * fire on prompts containing no examples at all.
251
+ */
252
+ const EXAMPLE_HEADER_TIERS: readonly RegExp[] = [
253
+ new RegExp(`${LABEL_PREFIX}(?:example|ejemplo)${LABEL_SUFFIX}`, 'i'),
254
+ new RegExp(`${LABEL_PREFIX}(?:input|entrada)${LABEL_SUFFIX}`, 'i'),
255
+ new RegExp(`${LABEL_PREFIX}(?:user|usuario|q)${LABEL_SUFFIX}`, 'i'),
256
+ ];
257
+
258
+ /**
259
+ * A labelled field inside a few-shot example — `Input:`, `Output:`, `A:`…
260
+ *
261
+ * Exported because the duplicate-line rule needs it: two examples that share
262
+ * an output line are demonstrating that two different inputs map to the *same*
263
+ * answer, which is often the whole point of including both. Deduplicating
264
+ * those lines leaves an example with no output at all — worse than the
265
+ * repetition it removed.
266
+ */
267
+ export const EXAMPLE_FIELD_LINE = new RegExp(
268
+ `${LABEL_PREFIX}(?:input|output|entrada|salida|example|ejemplo|user|usuario|assistant|asistente|q|a|question|answer|pregunta|respuesta)${LABEL_SUFFIX}`,
269
+ 'i',
270
+ );
271
+
272
+ export interface ExampleBlock {
273
+ text: string;
274
+ tokens: number;
275
+ }
276
+
277
+ export interface RedundantExample {
278
+ /** Index of the earlier example that already teaches this. */
279
+ duplicateOf: number;
280
+ index: number;
281
+ similarity: number;
282
+ tokens: number;
283
+ }
284
+
285
+ export interface ExampleAnalysis {
286
+ examples: ExampleBlock[];
287
+ redundant: RedundantExample[];
288
+ /** Tokens held by examples that repeat an earlier one. */
289
+ redundantTokens: number;
290
+ }
291
+
292
+ /**
293
+ * Similarity at which two examples are treated as repeating each other.
294
+ *
295
+ * Lower than the 0.92 the near-duplicate *rule* uses, because that rule
296
+ * deletes and this only reports.
297
+ *
298
+ * What this threshold actually catches, measured on realistic pairs: a
299
+ * copy-pasted example with one field changed scores ~0.89, a lightly edited
300
+ * copy ~0.80, two genuinely different examples ~0.20. A *paraphrase* — the
301
+ * same lesson taught in different words — scores ~0.54 and is deliberately
302
+ * NOT caught, because it sits close enough to the distinct case that catching
303
+ * it would mean flagging examples that teach different things.
304
+ *
305
+ * So this finds copy-paste accumulation, which is how few-shot blocks actually
306
+ * grow, and not semantic redundancy. Recognising that "arrived quickly" and
307
+ * "arrived fast" teach the same thing needs a model, not a word-set overlap;
308
+ * that belongs to the optional LLM pass, not here.
309
+ */
310
+ const EXAMPLE_SIMILARITY = 0.7;
311
+
312
+ /** Splits the prompt on one header style. */
313
+ function splitOnHeader(lines: readonly string[], header: RegExp): string[] {
314
+ const blocks: string[][] = [];
315
+ let current: string[] | null = null;
316
+ let inFence = false;
317
+
318
+ for (const line of lines) {
319
+ // A header inside a fenced block is part of the example, not a new one.
320
+ if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence;
321
+
322
+ if (!inFence && header.test(line)) {
323
+ current = [line];
324
+ blocks.push(current);
325
+ continue;
326
+ }
327
+ if (current) current.push(line);
328
+ }
329
+
330
+ return blocks.map((block) => trimToExample(block.join('\n'))).filter(Boolean);
331
+ }
332
+
333
+ /**
334
+ * Cuts a block off where the example stops and ordinary prose resumes.
335
+ *
336
+ * Without this the final example runs to the end of the prompt and absorbs
337
+ * every instruction that follows it, which inflates its length and drags its
338
+ * similarity to the others below any sensible threshold — so the one block
339
+ * most likely to be a duplicate is the one that never gets reported.
340
+ *
341
+ * A paragraph belongs to the example while it still contains a labelled field;
342
+ * the first that does not ends the block.
343
+ */
344
+ function trimToExample(block: string): string {
345
+ const paragraphs = block.split(/\n\s*\n/);
346
+ const kept = [paragraphs[0] ?? ''];
347
+
348
+ for (const paragraph of paragraphs.slice(1)) {
349
+ const isExampleContent = paragraph
350
+ .split('\n')
351
+ .some((line) => EXAMPLE_FIELD_LINE.test(line));
352
+ if (!isExampleContent) break;
353
+ kept.push(paragraph);
354
+ }
355
+
356
+ return kept.join('\n\n').trim();
357
+ }
358
+
359
+ /** Splits the prompt into labelled example blocks. */
360
+ export function findExamples(prompt: string, count: TokenCounter): ExampleBlock[] {
361
+ const lines = prompt.split('\n');
362
+
363
+ for (const header of EXAMPLE_HEADER_TIERS) {
364
+ const blocks = splitOnHeader(lines, header);
365
+ if (blocks.length >= 2) {
366
+ return blocks.map((text) => ({ text, tokens: count(text) }));
367
+ }
368
+ }
369
+
370
+ return [];
371
+ }
372
+
373
+ /**
374
+ * Reports examples that repeat what an earlier example already demonstrates.
375
+ *
376
+ * Each example is compared against every earlier one, and blamed on the first
377
+ * match: in a run of three near-identical examples the second and third are
378
+ * both reported against the first, rather than chaining.
379
+ */
380
+ export function analyzeExamples(prompt: string, count: TokenCounter): ExampleAnalysis {
381
+ const examples = findExamples(prompt, count);
382
+ const normalized = examples.map((e) => normalizeForCompare(e.text));
383
+ const redundant: RedundantExample[] = [];
384
+
385
+ for (let i = 1; i < examples.length; i++) {
386
+ for (let j = 0; j < i; j++) {
387
+ const similarity = jaccard(normalized[i]!, normalized[j]!);
388
+ if (similarity >= EXAMPLE_SIMILARITY) {
389
+ redundant.push({
390
+ duplicateOf: j,
391
+ index: i,
392
+ similarity,
393
+ tokens: examples[i]!.tokens,
394
+ });
395
+ break;
396
+ }
397
+ }
398
+ }
399
+
400
+ return {
401
+ examples,
402
+ redundant,
403
+ redundantTokens: redundant.reduce((sum, r) => sum + r.tokens, 0),
404
+ };
405
+ }
406
+
407
+ // --------------------------------------------------------------------------
408
+ // Output formats stated twice
409
+ // --------------------------------------------------------------------------
410
+
411
+ /**
412
+ * A prompt that shows its output schema in a code block, and then describes
413
+ * the same fields again in prose, is paying for the schema twice.
414
+ *
415
+ * The block is the version that survives: it is unambiguous, and the
416
+ * protection pass already guarantees Trazum will not touch it. The prose
417
+ * restatement is what can go — but only a human can tell a restatement from a
418
+ * clarification that happens to name the same fields, so this reports and does
419
+ * not cut.
420
+ */
421
+ export interface RestatedFormat {
422
+ /** Top-level keys found in the fenced schema. */
423
+ keys: string[];
424
+ /** Those keys that are also named in the prose outside the block. */
425
+ restatedKeys: string[];
426
+ /** Tokens held by the prose sentences that restate them. */
427
+ restatedTokens: number;
428
+ }
429
+
430
+ /** Fenced blocks, with their info string. */
431
+ function fencedBlocks(prompt: string): Array<{ lang: string; body: string }> {
432
+ const blocks: Array<{ lang: string; body: string }> = [];
433
+ const fence = /^([ \t]{0,3})(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)^\1\2[ \t]*$/gm;
434
+ let match: RegExpExecArray | null;
435
+ while ((match = fence.exec(prompt)) !== null) {
436
+ blocks.push({ lang: (match[3] ?? '').trim().toLowerCase(), body: match[4] ?? '' });
437
+ }
438
+ return blocks;
439
+ }
440
+
441
+ /**
442
+ * Top-level keys of a JSON-ish block.
443
+ *
444
+ * Deliberately a scan rather than `JSON.parse`: schemas in prompts are
445
+ * routinely illustrative — trailing commas, `...`, comments, a placeholder
446
+ * where a value goes — and refusing to read those would skip exactly the
447
+ * prompts worth checking. Only keys at nesting depth 1 count, so a nested
448
+ * field name cannot be mistaken for a top-level one.
449
+ */
450
+ function topLevelKeys(body: string): string[] {
451
+ const keys: string[] = [];
452
+ let depth = 0;
453
+ let inString = false;
454
+ let quote = '';
455
+ let current = '';
456
+
457
+ for (let i = 0; i < body.length; i++) {
458
+ const ch = body[i]!;
459
+
460
+ if (inString) {
461
+ if (ch === '\\') {
462
+ current += body[i + 1] ?? '';
463
+ i++;
464
+ continue;
465
+ }
466
+ if (ch === quote) {
467
+ inString = false;
468
+ // A string is a key only when the next non-space character is a colon.
469
+ const rest = body.slice(i + 1);
470
+ const colon = /^\s*:/.test(rest);
471
+ if (colon && depth === 1 && current) keys.push(current);
472
+ current = '';
473
+ continue;
474
+ }
475
+ current += ch;
476
+ continue;
477
+ }
478
+
479
+ if (ch === '"' || ch === "'") {
480
+ inString = true;
481
+ quote = ch;
482
+ current = '';
483
+ continue;
484
+ }
485
+ if (ch === '{' || ch === '[') depth++;
486
+ else if (ch === '}' || ch === ']') depth--;
487
+ }
488
+
489
+ return [...new Set(keys)];
490
+ }
491
+
492
+ /** Minimum keys that must be restated before this is worth reporting. */
493
+ const RESTATED_KEY_MINIMUM = 3;
494
+
495
+ /**
496
+ * Finds an output schema that the prose repeats.
497
+ *
498
+ * The threshold is deliberately blunt: naming one or two fields in prose is
499
+ * ordinary ("set `escalate` to true when the customer asks for a human"), and
500
+ * flagging that would make the advisory noise. Three or more, and the prose is
501
+ * walking the schema.
502
+ */
503
+ export function findRestatedFormat(prompt: string, count: TokenCounter): RestatedFormat | null {
504
+ const blocks = fencedBlocks(prompt);
505
+ const keys = [
506
+ ...new Set(
507
+ blocks
508
+ .filter((b) => b.lang === '' || /json|jsonc|json5|yaml|yml/.test(b.lang))
509
+ .flatMap((b) => topLevelKeys(b.body)),
510
+ ),
511
+ ].filter((k) => k.length >= 3);
512
+
513
+ if (keys.length < RESTATED_KEY_MINIMUM) return null;
514
+
515
+ // Prose only: the schema naming its own keys is not a restatement.
516
+ const prose = analysableText(prompt);
517
+ const proseSentences = sentences(prose);
518
+
519
+ const restatedKeys = new Set<string>();
520
+ const guiltySentences = new Set<number>();
521
+
522
+ proseSentences.forEach((sentence, index) => {
523
+ for (const key of keys) {
524
+ // Word boundary that also survives snake_case and kebab-case names.
525
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}_-])${escapeRegExp(key)}(?![\\p{L}\\p{N}_-])`, 'iu');
526
+ if (pattern.test(sentence)) {
527
+ restatedKeys.add(key);
528
+ guiltySentences.add(index);
529
+ }
530
+ }
531
+ });
532
+
533
+ if (restatedKeys.size < RESTATED_KEY_MINIMUM) return null;
534
+
535
+ const restatedTokens = count([...guiltySentences].map((i) => proseSentences[i]).join(' '));
536
+
537
+ return {
538
+ keys,
539
+ restatedKeys: [...restatedKeys],
540
+ restatedTokens,
541
+ };
542
+ }
543
+
544
+ function escapeRegExp(text: string): string {
545
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
546
+ }
547
+
548
+ // --------------------------------------------------------------------------
549
+ // An output schema the API could carry instead of the prompt
550
+ // --------------------------------------------------------------------------
551
+
552
+ /**
553
+ * A schema shown in the prompt that the request could carry as a parameter.
554
+ *
555
+ * Every provider worth naming now accepts a response schema alongside the
556
+ * message — `output_config.format`, `response_format`, `responseSchema`,
557
+ * whatever it is called this quarter. A prompt that spells the same shape out in
558
+ * a fenced block pays for it in input tokens on **every call**, and gets a
559
+ * weaker guarantee for the money: prose asks the model to comply, a parameter
560
+ * makes the decoder comply.
561
+ *
562
+ * So this is the rare finding that is not a trade-off. Moving a schema out is
563
+ * cheaper *and* stricter. What stops it being a rule is that Trazum cannot make
564
+ * the change: it edits prompts, and this is a change to the call around the
565
+ * prompt. It reports, names the tokens, and leaves the edit to whoever owns the
566
+ * client code.
567
+ *
568
+ * **The one way this could do harm, and what prevents it.** A fenced JSON block
569
+ * in a prompt is one of two completely different things. `Output format: {...}`
570
+ * is a contract and moving it is free. `Input: {...}` inside a few-shot example
571
+ * is *data the prompt needs*, and moving it breaks the prompt. Nothing here
572
+ * guesses which: a block counts only when a phrase from `OUTPUT_CUES_BY_LANGUAGE`
573
+ * appears in the text immediately before it. A schema with no such phrase is
574
+ * left alone, and a prompt in a language those dictionaries do not cover raises
575
+ * nothing at all — a false negative, stated as one, rather than an English cue
576
+ * matched inside Japanese prose and called a saving.
577
+ */
578
+ export interface MovableSchema {
579
+ /** Fenced blocks that an output cue introduces. */
580
+ blocks: number;
581
+ /** Top-level keys across all of them, deduplicated. */
582
+ keys: string[];
583
+ /** Tokens the blocks hold, fences included, since all of it leaves the prompt. */
584
+ tokens: number;
585
+ /** The cue that identified the first block, so the report can quote it. */
586
+ cue: string;
587
+ }
588
+
589
+ /** How far back to look for a cue introducing a block. */
590
+ const CUE_WINDOW = 240;
591
+
592
+ /** Fenced blocks with their offset, needed to read what precedes them. */
593
+ function fencedBlocksAt(prompt: string): Array<{ lang: string; body: string; at: number; raw: string }> {
594
+ const blocks: Array<{ lang: string; body: string; at: number; raw: string }> = [];
595
+ const fence = /^([ \t]{0,3})(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)^\1\2[ \t]*$/gm;
596
+ let match: RegExpExecArray | null;
597
+ while ((match = fence.exec(prompt)) !== null) {
598
+ blocks.push({
599
+ lang: (match[3] ?? '').trim().toLowerCase(),
600
+ body: match[4] ?? '',
601
+ at: match.index,
602
+ raw: match[0],
603
+ });
604
+ }
605
+ return blocks;
606
+ }
607
+
608
+ export function findMovableSchema(prompt: string, count: TokenCounter): MovableSchema | null {
609
+ const cued: Array<{ raw: string; keys: string[]; cue: string }> = [];
610
+
611
+ for (const block of fencedBlocksAt(prompt)) {
612
+ // Explicitly JSON-ish, or unlabelled but structured enough to be a shape.
613
+ // A labelled `python` block is somebody's code and never an output contract.
614
+ const jsonish = /^(json|jsonc|json5)$/.test(block.lang) || block.lang === '';
615
+ if (!jsonish) continue;
616
+
617
+ /**
618
+ * No minimum key *length* here, unlike the restated-format detector.
619
+ * That filter exists there to stop a two-letter key matching a word in
620
+ * prose; nothing is matched against prose here, so all it would do is
621
+ * undercount schemas whose fields are called `id` or `ok`. It was copied
622
+ * across in the first draft and a mutation run found it: deleting it changed
623
+ * no test, which is what a line with no reason looks like.
624
+ */
625
+ const keys = topLevelKeys(block.body);
626
+ // Fewer than three keys is an illustration, not a contract worth moving —
627
+ // the same threshold the restated-format advisory uses, for the same reason.
628
+ if (keys.length < RESTATED_KEY_MINIMUM) continue;
629
+
630
+ /**
631
+ * The window before the fence, normalised the way every other comparison in
632
+ * this package normalises: case folded, accents stripped, punctuation
633
+ * flattened. `Formato de salida:` and `FORMATO DE SALIDA —` are the same cue,
634
+ * and a dictionary that only matched one of them would cover Spanish on
635
+ * paper.
636
+ */
637
+ const lead = normalizeForCompare(prompt.slice(Math.max(0, block.at - CUE_WINDOW), block.at));
638
+ const cue = OUTPUT_CUES.find((phrase) => lead.includes(normalizeForCompare(phrase)));
639
+ if (cue === undefined) continue;
640
+
641
+ cued.push({ raw: block.raw, keys, cue });
642
+ }
643
+
644
+ if (cued.length === 0) return null;
645
+
646
+ return {
647
+ blocks: cued.length,
648
+ keys: [...new Set(cued.flatMap((block) => block.keys))],
649
+ tokens: count(cued.map((block) => block.raw).join('\n\n')),
650
+ cue: cued[0]?.cue ?? '',
651
+ };
652
+ }