@tduyng/pi-timbre 0.0.0-stage → 0.0.2

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.
package/src/report.ts ADDED
@@ -0,0 +1,205 @@
1
+ import type { Hit } from './scan.js'
2
+ import type { SegmentResult } from './segments.js'
3
+ import { cacheSummary, tallyIds, type Domain, type Effect, type Memory } from './memory.js'
4
+
5
+ const CAP = 10
6
+
7
+ export function formatHits(path: string, hits: Hit[]): string {
8
+ if (hits.length === 0) return ''
9
+
10
+ const shown = hits.slice(0, CAP)
11
+ const lines = shown.map((h) => ` ${h.line}: ${h.id}: ${h.hint} → ${truncate(h.text, 90)}`)
12
+ const more = hits.length > CAP ? `\n ... ${hits.length - CAP} more` : ''
13
+
14
+ return [
15
+ `timbre found ${hits.length} tell${hits.length === 1 ? '' : 's'} in ${path}:`,
16
+ lines.join('\n') + more,
17
+ 'Fix the ones in text you just wrote. Pre-existing tells elsewhere are not yours to fix.'
18
+ ].join('\n')
19
+ }
20
+
21
+ export type FixTarget = { path: string; register: string; hits: Hit[]; segments?: SegmentResult }
22
+
23
+ export function formatFixRequest(targets: FixTarget[], instruction = '', fullRules = ''): string {
24
+ const total = targets.reduce((sum, t) => sum + t.hits.length, 0)
25
+ const blocks = targets.map((t) => {
26
+ const header = `${t.path} (register: ${t.register})`
27
+ const tells = groupById(t.hits)
28
+ if (t.segments === undefined) return [header, ...tells].join('\n')
29
+ const { flaggedFraction, verdict, spans } = t.segments
30
+ const pct = `${(flaggedFraction * 100).toFixed(1)}%`
31
+ const spanList =
32
+ spans.length === 0
33
+ ? 'no flagged spans'
34
+ : spans
35
+ .map((s) =>
36
+ s.startLine === s.endLine ? ` line ${s.startLine}` : ` lines ${s.startLine}-${s.endLine}`
37
+ )
38
+ .join('\n')
39
+ const advice =
40
+ verdict === 'clean'
41
+ ? 'Light touch only. The scan found scattered tells: fix them, leave the rest.'
42
+ : verdict === 'heavy'
43
+ ? 'Most of this file reads machine-shaped. Restart from your own outline, keep the ideas, rewrite every paragraph.'
44
+ : 'Some spans are machine-shaped, the rest reads human. Fix the flagged spans, leave the clean ones alone.'
45
+ return [
46
+ header,
47
+ `${pct} of this file's prose is flagged (${verdict}).`,
48
+ 'Flagged spans:',
49
+ spanList,
50
+ ...tells,
51
+ advice
52
+ ].join('\n')
53
+ })
54
+ const found =
55
+ total === 0
56
+ ? 'found no mechanical tell, so this one is a judgment pass'
57
+ : `found ${total} tell${total === 1 ? '' : 's'}`
58
+
59
+ return [
60
+ `timbre scanned ${targets.length} file${targets.length === 1 ? '' : 's'} on request and ${found}.`,
61
+ blocks.join('\n\n'),
62
+ ...(instruction === '' ? [] : [`The user asked for this alongside the paths: ${instruction}`]),
63
+ `Rewrite these files so they read as human. ${REWRITE_PROCEDURE}`,
64
+ ...(fullRules === ''
65
+ ? []
66
+ : [`The complete constraint set for the rewrite (every rule, not the short version):\n\n${fullRules}`])
67
+ ].join('\n\n')
68
+ }
69
+
70
+ export function formatFindRequest(instruction: string, fullRules = ''): string {
71
+ return [
72
+ 'timbre: a prose fix was requested without a filename the command could resolve.',
73
+ `The user asked for this: ${instruction}`,
74
+ `Work out which files they mean from what was said earlier in this conversation, and ask only if it is still ambiguous after looking. Then rewrite them so they read as human. ${REWRITE_PROCEDURE}`,
75
+ ...(fullRules === ''
76
+ ? []
77
+ : [`The complete constraint set for the rewrite (every rule, not the short version):\n\n${fullRules}`])
78
+ ].join('\n\n')
79
+ }
80
+
81
+ const REWRITE_PROCEDURE =
82
+ 'Call timbre_review_guide first and work in the order it gives: triage every hit before fixing any, leave the ones the text quotes or is discussing, then read once for what no scanner sees. If the outline came from the model, restart from your own outline, paraphrase cannot save it. Reordering your own sentences is always safe; the test is clause by clause, whose idea each carries, and a borrowed idea in swapped words must go. Edit in place. Removing the tells is half the job: the rewrite also has to leave a voice behind, read naturally, and stay easy to follow, with short paragraphs, one idea per sentence, and concrete specifics over abstractions. It must not drop a claim, condition, or instruction the original carried. There is no timbre binary to run; `timbre_review_guide` is the only timbre tool.'
83
+
84
+ const EXCERPTS = 2
85
+
86
+ function groupById(hits: Hit[], excerpt = false): string[] {
87
+ const groups = new Map<string, { hint: string; lines: number[]; texts: string[] }>()
88
+ for (const hit of hits) {
89
+ const group = groups.get(hit.id) ?? { hint: hit.hint, lines: [], texts: [] }
90
+ if (hit.line > 0) group.lines.push(hit.line)
91
+ if (hit.text !== '') group.texts.push(`${hit.line}: ${truncate(hit.text, 90)}`)
92
+ groups.set(hit.id, group)
93
+ }
94
+ return [...groups.entries()]
95
+ .toSorted(([aId, a], [bId, b]) => b.lines.length - a.lines.length || aId.localeCompare(bId))
96
+ .map(([id, { hint, lines, texts }]) => {
97
+ const where = lines.length === 0 ? 'whole file' : `line${lines.length === 1 ? '' : 's'} ${lines.join(', ')}`
98
+ const quoted = excerpt
99
+ ? texts
100
+ .slice(0, EXCERPTS)
101
+ .map((t) => `\n ${t}`)
102
+ .join('')
103
+ : ''
104
+ return ` ${id} (${where}): ${hint}${quoted}`
105
+ })
106
+ }
107
+
108
+ function groupByRule(hits: Hit[]): string[] {
109
+ const order: string[] = []
110
+ const grouped = new Map<string, { lines: (number | '?')[]; hint: string }>()
111
+ for (const hit of hits) {
112
+ let row = grouped.get(hit.id)
113
+ if (row === undefined) {
114
+ row = { lines: [], hint: hit.hint }
115
+ grouped.set(hit.id, row)
116
+ order.push(hit.id)
117
+ }
118
+ row.lines.push(hit.line || '?')
119
+ }
120
+ return order.map((id) => {
121
+ const row = grouped.get(id)
122
+ return ` ${row?.lines.join(', ')}: ${id}: ${row?.hint ?? ''}`
123
+ })
124
+ }
125
+
126
+ export function formatCodeFindings(hits: Array<Hit & { path: string }>): string {
127
+ if (hits.length === 0) return ''
128
+ const byPath = new Map<string, typeof hits>()
129
+ for (const hit of hits) {
130
+ const row = byPath.get(hit.path)
131
+ if (row === undefined) byPath.set(hit.path, [hit])
132
+ else row.push(hit)
133
+ }
134
+ const blocks = [...byPath].map(([path, rows]) => {
135
+ const shown = rows.slice(0, CAP)
136
+ const more = rows.length > CAP ? `\n ... ${rows.length - CAP} more` : ''
137
+ const noun = rows.length === 1 ? 'code tell' : 'code tells'
138
+ return [`timbre found ${rows.length} ${noun} in ${path}:`, groupByRule(shown).join('\n') + more].join('\n')
139
+ })
140
+ blocks.push(
141
+ `The message is the rule. Prefer inference, \`as const\`, \`satisfies\`, named domain types, and boundary parsing over widening then asserting. The agreements are in AGENTS.md, section Code.`
142
+ )
143
+ return blocks.join('\n\n')
144
+ }
145
+
146
+ export function formatStats(memory: Memory, effects: Array<[Domain, Effect]>, preventionOn: boolean): string {
147
+ const out: string[] = [`timbre: prevention ${preventionOn ? 'on' : 'off'}`]
148
+
149
+ const totalTells = memory.samples.reduce((a, s) => a + s.hits, 0)
150
+ if (memory.samples.length === 0) {
151
+ out.push('No samples recorded yet. Write some prose or code and check back.')
152
+ return out.join('\n')
153
+ }
154
+
155
+ out.push(`${memory.samples.length} samples, ${totalTells} tells recorded.`)
156
+
157
+ for (const [domain, effect] of effects) {
158
+ const { withPrevention: on, withoutPrevention: off, delta } = effect
159
+ if (on.samples === 0 && off.samples === 0) continue
160
+
161
+ out.push('')
162
+ out.push(` ${domain}`)
163
+ out.push(` prevention off: ${fmt(off.density)} tells/1k chars over ${off.samples} samples (inferred)`)
164
+ out.push(` prevention on: ${fmt(on.density)} tells/1k chars over ${on.samples} samples (inferred)`)
165
+
166
+ if (delta === null) {
167
+ out.push(' Need samples on both sides to compare. `/timbre off` builds a baseline.')
168
+ } else {
169
+ const verdict = delta > 0 ? `${fmt(delta)}% fewer tells` : `${fmt(Math.abs(delta))}% more tells`
170
+ out.push(` Effect: ${verdict} with prevention on.`)
171
+ }
172
+ }
173
+
174
+ const cache = cacheSummary(memory, true)
175
+ if (cache !== null) {
176
+ out.push('')
177
+ out.push(
178
+ ` cache (prevention on): avg ${Math.round(cache.avgCacheRead)} read / ${Math.round(cache.avgCacheWrite)} write tokens/turn over ${cache.turns} turns`
179
+ )
180
+ if (cache.avgCacheWrite > cache.avgCacheRead) {
181
+ out.push(' High write relative to read: the block is not staying cached turn to turn.')
182
+ }
183
+ }
184
+
185
+ const top = [...tallyIds(memory.samples)]
186
+ .toSorted(([aId, a], [bId, b]) => b - a || aId.localeCompare(bId))
187
+ .slice(0, 5)
188
+ if (top.length > 0) {
189
+ out.push('')
190
+ out.push(' Most frequent:')
191
+ for (const [id, n] of top) {
192
+ out.push(` ${n}x ${id}`)
193
+ }
194
+ }
195
+
196
+ return out.join('\n')
197
+ }
198
+
199
+ function fmt(n: number): string {
200
+ return n.toFixed(1)
201
+ }
202
+
203
+ function truncate(s: string, max: number): string {
204
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`
205
+ }
package/src/rules.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const GUIDANCE = join(dirname(fileURLToPath(import.meta.url)), 'guidance')
6
+
7
+ export const CATALOG_PATH = join(GUIDANCE, 'catalog.md')
8
+
9
+ const cache = new Map<string, string>()
10
+
11
+ function load(name: string): string {
12
+ const cached = cache.get(name)
13
+ if (cached !== undefined) return cached
14
+
15
+ let raw: string
16
+ try {
17
+ raw = readFileSync(join(GUIDANCE, `${name}.md`), 'utf-8')
18
+ } catch {
19
+ return ''
20
+ }
21
+ const text = raw.replace(/^<!--[\s\S]*?-->\s*/, '').trim()
22
+ cache.set(name, text)
23
+ return text
24
+ }
25
+
26
+ /** The one rule set: injected every turn and sent with a rewrite. */
27
+ export function generationRules(): string {
28
+ return load('catalog')
29
+ }
30
+
31
+ export function catalogAvailable(): boolean {
32
+ return load('catalog') !== ''
33
+ }
34
+
35
+ export function reviewGuide(): string {
36
+ return load('review')
37
+ }
package/src/scan.ts ADDED
@@ -0,0 +1,328 @@
1
+ import { PATTERNS, TIER_2_WORDS, TIER_3_PHRASES, type Pattern, type Tier } from './patterns.js'
2
+ import {
3
+ burstiness,
4
+ hasClauseStack,
5
+ hasLongSentenceRun,
6
+ hasStaccatoRun,
7
+ isMonotone,
8
+ repeatedOpening,
9
+ wordCount
10
+ } from './stats.js'
11
+
12
+ export type Hit = {
13
+ line: number
14
+ id: string
15
+ tier: Tier
16
+ hint: string
17
+ text: string
18
+ }
19
+
20
+ export type ScanResult = { hits: Hit[]; chars: number }
21
+
22
+ const IGNORE_MARKER = /<!--[ \t]*lint-disable[ \t]+--[ \t]*([^\n]*)/
23
+ const MIN_REASON_CHARS = 8
24
+
25
+ const FENCE = /^\s*(`{3,}|~{3,})/
26
+ const TABLE_ROW = /^\s*\|/
27
+ const CODE_SPAN = /`[^`]*`/g
28
+ const URL_OR_LINK = /https?:\/\/\S+|\]\([^)]*\)/g
29
+
30
+ export function isIgnored(text: string): boolean {
31
+ const reason = IGNORE_MARKER.exec(text.slice(0, 400))?.[1]
32
+ return reason !== undefined && reason.split('-->')[0]!.trim().length >= MIN_REASON_CHARS
33
+ }
34
+
35
+ type Split = {
36
+ lines: Array<[number, string]>
37
+ unclosedFence: number | undefined
38
+ }
39
+
40
+ function proseLines(text: string): Split {
41
+ const lines: Array<[number, string]> = []
42
+ let open: { marker: string; length: number } | undefined
43
+ let openedAt: number | undefined
44
+
45
+ for (const [i, raw] of text.split('\n').entries()) {
46
+ const indented = raw.startsWith(' ')
47
+ const m = FENCE.exec(raw)
48
+ const run = m?.[1]
49
+
50
+ if (open === undefined) {
51
+ if (indented || TABLE_ROW.test(raw)) continue
52
+ if (run) {
53
+ open = { marker: run.charAt(0), length: run.length }
54
+ openedAt = i + 1
55
+ continue
56
+ }
57
+ lines.push([i + 1, raw])
58
+ continue
59
+ }
60
+
61
+ if (run && run[0] === open.marker && run.length >= open.length && /^\s*(`{3,}|~{3,})\s*$/.test(raw)) {
62
+ open = undefined
63
+ openedAt = undefined
64
+ }
65
+ }
66
+ return { lines, unclosedFence: openedAt }
67
+ }
68
+
69
+ export function scan(text: string, only?: Tier[]): Hit[] {
70
+ return analyze(text, only).hits
71
+ }
72
+
73
+ export function scanProse(text: string, only?: Tier[]): ScanResult {
74
+ return analyze(text, only)
75
+ }
76
+
77
+ function analyze(text: string, only?: Tier[]): ScanResult {
78
+ if (isIgnored(text)) return { hits: [], chars: 0 }
79
+
80
+ const active: Pattern[] = only ? PATTERNS.filter((p) => only.includes(p.tier)) : PATTERNS
81
+ const wantCluster = !only || only.includes(2)
82
+ const wantTier1 = !only || only.includes(1)
83
+ const hits: Hit[] = []
84
+ const clusterHits: Hit[] = []
85
+ const { lines, unclosedFence } = proseLines(text)
86
+
87
+ let paraStart: number | undefined
88
+ let paraText = ''
89
+
90
+ const flushPara = (): void => {
91
+ if (wantCluster && paraStart !== undefined) {
92
+ if (countTier2(paraText) >= TIER_2_CLUSTER_MIN) {
93
+ clusterHits.push({
94
+ line: paraStart,
95
+ id: 'tier-2-cluster',
96
+ tier: 2,
97
+ hint: 'Several tier-2 words in one paragraph. Use plain words or spread them across sections.',
98
+ text: ''
99
+ })
100
+ }
101
+ const t3 = tier3Info(paraText)
102
+ if (t3.distinct >= TIER_3_CLUSTER_MIN || t3.doubled) {
103
+ clusterHits.push({
104
+ line: paraStart,
105
+ id: 'tier-3-phrase-cluster',
106
+ tier: 2,
107
+ hint: 'Several boilerplate phrases in one paragraph. State the specific claim, or vary genuinely.',
108
+ text: ''
109
+ })
110
+ }
111
+ }
112
+ paraText = ''
113
+ paraStart = undefined
114
+ }
115
+
116
+ for (const [line, raw] of lines) {
117
+ const noCode = raw.replaceAll(CODE_SPAN, ' ')
118
+ const noUrl = noCode.replaceAll(URL_OR_LINK, ' ')
119
+
120
+ for (const p of active) {
121
+ const target = p.scope === 'url' ? noCode : noUrl
122
+ if (p.re.test(target)) {
123
+ hits.push({ line, id: p.id, tier: p.tier, hint: p.hint, text: raw.trim() })
124
+ }
125
+ }
126
+
127
+ if (raw.trim() === '') flushPara()
128
+ else {
129
+ if (paraStart === undefined) paraStart = line
130
+ paraText += ' ' + noUrl
131
+ }
132
+ }
133
+
134
+ if (wantTier1) {
135
+ for (const [line, raw] of lines) {
136
+ if (isTitleCase(raw)) {
137
+ hits.push({
138
+ line,
139
+ id: 'title-case-heading',
140
+ tier: 1,
141
+ hint: 'Sentence case: capitalise the first word and proper nouns only.',
142
+ text: raw.trim()
143
+ })
144
+ }
145
+ }
146
+ }
147
+
148
+ flushPara()
149
+
150
+ if (wantCluster) {
151
+ const nonEmpty = lines.filter(([, l]) => l.trim() !== '')
152
+ const blankLines = lines.filter(([, l]) => l.trim() === '').length
153
+ const structured =
154
+ nonEmpty.filter(([, l]) => /^\s*([-*]|\d+\.|#{1,6}\s)/.test(l)).length / Math.max(1, nonEmpty.length)
155
+ const fullMasked = lines.map(([, l]) => l.replaceAll(CODE_SPAN, ' ').replaceAll(URL_OR_LINK, ' ')).join(' ')
156
+ const longEnough = wordCount(fullMasked) >= 50
157
+ if (structured < 0.3 && blankLines >= 1 && longEnough) {
158
+ if (isMonotone(burstiness(fullMasked))) {
159
+ clusterHits.push({
160
+ line: 1,
161
+ id: 'monotone-cadence',
162
+ tier: 2,
163
+ hint: 'Sentence lengths are unusually uniform across the whole piece. Vary them; human prose wanders.',
164
+ text: ''
165
+ })
166
+ }
167
+ }
168
+ if (structured < 0.3) {
169
+ if (hasStaccatoRun(fullMasked)) {
170
+ clusterHits.push({
171
+ line: 1,
172
+ id: 'staccato-run',
173
+ tier: 2,
174
+ hint: 'Four or more short sentences in a row. One is emphasis, a run is engineering.',
175
+ text: ''
176
+ })
177
+ }
178
+ const opening = repeatedOpening(fullMasked)
179
+ if (opening !== null) {
180
+ clusterHits.push({
181
+ line: 1,
182
+ id: 'repeated-opening',
183
+ tier: 2,
184
+ hint: 'Three or more consecutive sentences open the same way. Merge sentences or vary the opening.',
185
+ text: opening
186
+ })
187
+ }
188
+ if (hasLongSentenceRun(fullMasked)) {
189
+ clusterHits.push({
190
+ line: 1,
191
+ id: 'long-sentence-run',
192
+ tier: 2,
193
+ hint: 'Three or more long sentences in a row. Split one; one idea per sentence.',
194
+ text: ''
195
+ })
196
+ }
197
+ if (hasClauseStack(fullMasked)) {
198
+ clusterHits.push({
199
+ line: 1,
200
+ id: 'clause-stack',
201
+ tier: 2,
202
+ hint: 'Several sentences stack three or more subordinate clauses. Split one; one clause, one idea.',
203
+ text: ''
204
+ })
205
+ }
206
+ }
207
+ for (const [start, words] of paragraphs(lines)) {
208
+ if (words >= WALL_WORDS) {
209
+ clusterHits.push({
210
+ line: start,
211
+ id: 'wall-paragraph',
212
+ tier: 2,
213
+ hint: 'One paragraph carries the whole argument. Split it; short paragraphs are easier to read.',
214
+ text: ''
215
+ })
216
+ }
217
+ }
218
+ }
219
+
220
+ if (unclosedFence !== undefined) {
221
+ hits.push({
222
+ line: unclosedFence,
223
+ id: 'unclosed-fence',
224
+ tier: 'forensic',
225
+ hint: 'Code fence opened here and never closed, so nothing after it was scanned.',
226
+ text: ''
227
+ })
228
+ }
229
+
230
+ const chars = lines.reduce((a, [, l]) => a + l.replaceAll(/\s/g, '').length, 0)
231
+ return { hits: [...hits, ...clusterHits], chars }
232
+ }
233
+
234
+ const TIER_2_CLUSTER_MIN = 3
235
+
236
+ const TIER_3_CLUSTER_MIN = 3
237
+
238
+ const WALL_WORDS = 120
239
+
240
+ function paragraphs(lines: Array<[number, string]>): Array<[number, number]> {
241
+ const out: Array<[number, number]> = []
242
+ let start = 0
243
+ let words = 0
244
+ const flush = (): void => {
245
+ if (words > 0) out.push([start, words])
246
+ words = 0
247
+ start = 0
248
+ }
249
+ for (const [line, raw] of lines) {
250
+ if (raw.trim() === '') {
251
+ flush()
252
+ continue
253
+ }
254
+ if (words === 0) start = line
255
+ words += raw
256
+ .replaceAll(CODE_SPAN, ' ')
257
+ .replaceAll(URL_OR_LINK, ' ')
258
+ .split(/\s+/)
259
+ .filter((w) => w !== '').length
260
+ }
261
+ flush()
262
+ return out
263
+ }
264
+
265
+ const TIER_2_RE = new RegExp(`\\b(?:${TIER_2_WORDS.join('|')})\\b`, 'gi')
266
+
267
+ function countTier2(masked: string): number {
268
+ TIER_2_RE.lastIndex = 0
269
+ return (masked.match(TIER_2_RE) ?? []).length
270
+ }
271
+
272
+ const TIER_3_RES = TIER_3_PHRASES.map(
273
+ (p) => new RegExp('\\b' + p.replace(/-/g, '[- ]').replace(/\\s+/g, '\\s+') + '\\b', 'gi')
274
+ )
275
+
276
+ type Tier3Info = { distinct: number; doubled: boolean }
277
+
278
+ function tier3Info(masked: string): Tier3Info {
279
+ let distinct = 0
280
+ let doubled = false
281
+ for (const re of TIER_3_RES) {
282
+ const n = (masked.match(re) ?? []).length
283
+ if (n >= 1) distinct++
284
+ if (n >= 2) doubled = true
285
+ }
286
+ return { distinct, doubled }
287
+ }
288
+
289
+ const SMALL = new Set([
290
+ 'a',
291
+ 'an',
292
+ 'and',
293
+ 'as',
294
+ 'at',
295
+ 'but',
296
+ 'by',
297
+ 'for',
298
+ 'in',
299
+ 'of',
300
+ 'on',
301
+ 'or',
302
+ 'the',
303
+ 'to',
304
+ 'vs',
305
+ 'with'
306
+ ])
307
+
308
+ function isTitleCase(raw: string): boolean {
309
+ const m = /^#{1,6}\s+(.+)/.exec(raw)
310
+ if (!m?.[1]) return false
311
+ const words = m[1].match(/[A-Za-z][\w'-]*/g) ?? []
312
+ const caps = words
313
+ .slice(1)
314
+ .filter((w) => w[0] === w[0]?.toUpperCase() && !SMALL.has(w.toLowerCase()) && w !== w.toUpperCase())
315
+ return words.length >= 4 && caps.length >= 3
316
+ }
317
+
318
+ export function proseLineChars(text: string): Array<[number, number]> {
319
+ if (isIgnored(text)) return []
320
+ return proseLines(text).lines.map(([line, raw]) => [line, raw.replaceAll(/\s/g, '').length])
321
+ }
322
+
323
+ export function proseLength(text: string): number {
324
+ if (isIgnored(text)) return 0
325
+ let chars = 0
326
+ for (const [, l] of proseLines(text).lines) chars += l.replaceAll(/\s/g, '').length
327
+ return chars
328
+ }
@@ -0,0 +1,81 @@
1
+ import type { Hit } from './scan.js'
2
+
3
+ export type ProseLine = {
4
+ line: number
5
+ chars: number
6
+ flagged: boolean
7
+ }
8
+
9
+ export type Span = {
10
+ startLine: number
11
+ endLine: number
12
+ chars: number
13
+ }
14
+
15
+ export type Verdict = 'clean' | 'mixed' | 'heavy'
16
+
17
+ export type SegmentResult = {
18
+ lines: ProseLine[]
19
+ spans: Span[]
20
+ flaggedFraction: number
21
+ verdict: Verdict
22
+ }
23
+
24
+ const DEFAULT_GAP = 3
25
+
26
+ const HEAVY_THRESHOLD = 0.8
27
+ const CLEAN_THRESHOLD = 0.1
28
+
29
+ export function aggregateSpans(lineChars: Array<[number, number]>, hits: Hit[], gap = DEFAULT_GAP): SegmentResult {
30
+ const totalChars = lineChars.reduce((a, [, c]) => a + c, 0)
31
+
32
+ if (lineChars.length === 0 || totalChars === 0) {
33
+ return { lines: [], spans: [], flaggedFraction: 0, verdict: 'clean' }
34
+ }
35
+
36
+ const flaggedLineSet = new Set<number>()
37
+ for (const h of hits) {
38
+ if (h.tier === 'forensic' || h.tier === 1) flaggedLineSet.add(h.line)
39
+ }
40
+
41
+ const lines: ProseLine[] = lineChars.map(([line, chars]) => ({
42
+ line,
43
+ chars,
44
+ flagged: flaggedLineSet.has(line)
45
+ }))
46
+
47
+ const spans: Span[] = []
48
+ let current: Span | null = null
49
+ let cleanSince = 0
50
+
51
+ const flush = (): void => {
52
+ if (current !== null) {
53
+ spans.push(current)
54
+ current = null
55
+ }
56
+ cleanSince = 0
57
+ }
58
+
59
+ for (const l of lines) {
60
+ if (l.flagged) {
61
+ if (current === null) {
62
+ current = { startLine: l.line, endLine: l.line, chars: l.chars }
63
+ } else {
64
+ const c: Span = current
65
+ current = { startLine: c.startLine, endLine: l.line, chars: c.chars + l.chars }
66
+ }
67
+ cleanSince = 0
68
+ continue
69
+ }
70
+ cleanSince++
71
+ if (cleanSince >= gap) flush()
72
+ }
73
+ flush()
74
+
75
+ const flaggedChars = spans.reduce((a, s) => a + s.chars, 0)
76
+ const flaggedFraction = totalChars === 0 ? 0 : flaggedChars / totalChars
77
+ const verdict: Verdict =
78
+ flaggedFraction >= HEAVY_THRESHOLD ? 'heavy' : flaggedFraction <= CLEAN_THRESHOLD ? 'clean' : 'mixed'
79
+
80
+ return { lines, spans, flaggedFraction, verdict }
81
+ }