@solaqua/ghosts 0.1.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 (37) hide show
  1. package/README.md +41 -0
  2. package/common/AGENTS.md +19 -0
  3. package/engineering/skills/code-review/SKILL.md +46 -0
  4. package/engineering/skills/deep-module/SKILL.md +9 -0
  5. package/engineering/skills/explain-diff/SKILL.md +24 -0
  6. package/engineering/skills/github-pr/SKILL.md +36 -0
  7. package/engineering/skills/loop-coding/SKILL.md +46 -0
  8. package/engineering/skills/loop-plan/SKILL.md +229 -0
  9. package/engineering/skills/socratic-interview/SKILL.md +22 -0
  10. package/engineering/skills/task-handoff/SKILL.md +37 -0
  11. package/engineering/skills/work-breakdown/SKILL.md +26 -0
  12. package/manifest.json +3 -0
  13. package/package.json +22 -0
  14. package/registry/skul.refs.json +55 -0
  15. package/research/skills/decision-research/SKILL.md +56 -0
  16. package/research/skills/loop-research/SKILL.md +120 -0
  17. package/tools/skills/vibe-notion/SKILL.md +138 -0
  18. package/tools/skills/vibe-notion/UPSTREAM.md +15 -0
  19. package/tools/skills/vibe-notion/references/batch-operations.md +100 -0
  20. package/tools/skills/vibe-notion/references/block-types.md +73 -0
  21. package/tools/skills/vibe-notion/references/common-patterns.md +165 -0
  22. package/tools/skills/vibe-notion/references/output-format.md +126 -0
  23. package/tools/skills/vibe-notion/templates/create-page.sh +32 -0
  24. package/tools/skills/vibe-notion/templates/query-database.sh +12 -0
  25. package/tools/skills/vibe-notion/templates/read-page.sh +16 -0
  26. package/tools/skills/vibe-notion/templates/workspace-overview.sh +19 -0
  27. package/writing/skills/cold-reader/README.md +7 -0
  28. package/writing/skills/cold-reader/SKILL.md +48 -0
  29. package/writing/skills/eli5/SKILL.md +7 -0
  30. package/writing/skills/humanize-korean/SKILL.md +94 -0
  31. package/writing/skills/humanize-korean/references/ai-tell-taxonomy.md +149 -0
  32. package/writing/skills/humanize-korean/references/metrics.py +809 -0
  33. package/writing/skills/humanize-korean/references/quick-rules.md +107 -0
  34. package/writing/skills/humanize-korean/references/rewriting-playbook.md +122 -0
  35. package/writing/skills/humanize-korean/tests/test_metrics.py +176 -0
  36. package/writing/skills/loop-skills/SKILL.md +101 -0
  37. package/writing/skills/rewrite/SKILL.md +50 -0
@@ -0,0 +1,809 @@
1
+ """Humanize Korean quantitative metrics.
2
+
3
+ Standard-library-only helper for the humanize-korean skill. The numbers are
4
+ not a detector verdict by themselves; they give the model a compact read on
5
+ comma habits, repeated AI phrases, translationese, and post-editese signals.
6
+
7
+ CLI:
8
+ python metrics.py --input input.txt --genre essay
9
+ python metrics.py --text $'첫 문장입니다.\n둘째 문장입니다.' --genre essay
10
+ python metrics.py --before original.md --after revised.md --ignore-markup
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import difflib
17
+ import json
18
+ import re
19
+ import sys
20
+ from collections.abc import Sequence
21
+ from statistics import StatisticsError, mean
22
+ from typing import Any
23
+
24
+ VERSION = "2.1-standalone"
25
+
26
+ _SENTENCE_SPLIT_RE = re.compile(r"(?<=[\.!?。])\s+")
27
+ _PARAGRAPH_SPLIT_RE = re.compile(r"\n\s*\n")
28
+ _EOJEOL_SPLIT_RE = re.compile(r"\s+")
29
+ _PUNCT_STRIP_RE = re.compile(r"[\.,!?;:\(\)\[\]\{\}\"'`~、。“”‘’\-]+")
30
+ _ENDING_COMMA_RE = re.compile(r"(?:고|며|지만|면서|아서|어서)\s*,")
31
+ _ENDING_BOUNDARY_RE = re.compile(r"(?:고|며|지만|면서|아서|어서)(?=[\s,\.!?、。]|$)")
32
+ _ENDING_FINAL_RE = re.compile(r"([가-힣]{1,3})[\.!?。]?\s*$")
33
+
34
+ _CONCLUSION_PIVOTS = (
35
+ "결론적으로",
36
+ "따라서",
37
+ "이를 통해",
38
+ "그러므로",
39
+ "요약하면",
40
+ "정리하면",
41
+ "정리하자면",
42
+ )
43
+ _SIGNATURE_PHRASES = (
44
+ "시사하는 바가 크다",
45
+ "주목할 만하다",
46
+ "간과할 수 없다",
47
+ )
48
+ _SAFE_BALANCES = ("양쪽 모두", "두 가지 모두", "장점도 있지만", "신중하게", "균형")
49
+ _HANJA_SUFFIXES = ("성", "적", "화", "도", "력", "감", "원")
50
+ _DECLARATIVE_ENDINGS = ("한다", "된다", "이다")
51
+ _PROGRESSIVE_RE = re.compile(r"고\s*있(?:다|었|는|을|던|는다)")
52
+ _DOUBLE_PARTICLE_RE = re.compile(r"(?:에서의|에로의|으로의|에의|으로부터의|로부터의)")
53
+ _BY_PASSIVE_RE = re.compile(
54
+ r"에\s*의(?:해|하여)\s+\S{0,12}?(?:되|받|당하|지)(?:다|었|어|ㄴ다|는다|는|ㄹ|을)"
55
+ )
56
+ _PRONOUN_RE = re.compile(
57
+ r"(?:그녀(?:는|가|를|의|에게|와|도|만)?"
58
+ r"|그것(?:은|이|을|의|에|에게)?"
59
+ r"|그들(?:은|이|을|의|에게|과|도)?"
60
+ r"|그(?:는|가|를|의|에게|와|도|만)(?=\s|[\.,!?]|$))"
61
+ )
62
+
63
+ _DOUBLE_PASSIVE_TOKENS = (
64
+ "되어진다",
65
+ "되어졌다",
66
+ "되어진",
67
+ "되어지는",
68
+ "여지다",
69
+ "여진다",
70
+ "여졌다",
71
+ "잊혀진",
72
+ "보여진다",
73
+ "보여졌다",
74
+ "보여진",
75
+ "쓰여진다",
76
+ "쓰여졌다",
77
+ "쓰여진",
78
+ "닫혀진",
79
+ "열려진",
80
+ )
81
+ _INANIMATE_DEUL_TOKENS = (
82
+ "데이터들",
83
+ "정보들",
84
+ "결과들",
85
+ "연구들",
86
+ "아이디어들",
87
+ "방법들",
88
+ "문제들",
89
+ "의견들",
90
+ "시스템들",
91
+ "기술들",
92
+ "사실들",
93
+ "사례들",
94
+ "이론들",
95
+ "개념들",
96
+ "현상들",
97
+ "특징들",
98
+ "요소들",
99
+ "원인들",
100
+ "영향들",
101
+ "변화들",
102
+ )
103
+ _HAVE_MAKE_LITERAL_TOKENS = (
104
+ "가지고 있다",
105
+ "가지고있다",
106
+ "가지고 있는",
107
+ "갖고 있다",
108
+ "갖고있는",
109
+ "을 가지다",
110
+ "를 가지다",
111
+ "을 가졌",
112
+ "를 가졌",
113
+ "회의를 가지",
114
+ "결정을 내리",
115
+ "결정을 내렸",
116
+ )
117
+
118
+ # Later upstream calibration treats these as review evidence, not automatic
119
+ # rewrite verdicts. They are intentionally exposed separately from the
120
+ # coarse risk score so a human can inspect their context.
121
+ _ANTITHESIS_RE = re.compile(
122
+ r"(?:가|이)\s*아니라|이기\s*이전에|되기\s*이전에|이기보다|것은\s*아니다"
123
+ )
124
+ _CLEAR_PREDICATE_RE = re.compile(r"(?:은|는|이|가)\s*(?:명확|분명)(?:하다|합니다|하며|하고|해졌|하지만)")
125
+ _NOT_LONGER_RE = re.compile(r"더\s*이상[^.!?。]{0,25}(?:않|아니|없|못하|불가)")
126
+ _FOUNDATION_METAPHOR_RE = re.compile(
127
+ r"(?:발판|토대|초석|주춧돌|교두보)(?:을|를)\s?(?:마련|놓|다지)|"
128
+ r"(?:지평|활로)(?:을|를)\s?(?:열|연다|열었|열어)"
129
+ )
130
+ _CONCEPTUAL_METAPHORS = (
131
+ "잠식",
132
+ "청사진",
133
+ "적신호",
134
+ "경고등",
135
+ "신호탄",
136
+ "움켜쥐다",
137
+ "뿌리내리다",
138
+ "짓누르다",
139
+ "청구서",
140
+ "과실",
141
+ "짊어지다",
142
+ "쥐다",
143
+ )
144
+
145
+ _MARKUP_ONLY_LINE_RE = re.compile(
146
+ r"^\s*(?:```.*|~~~.*|-{3,}|\*{3,}|={3,}|\|[\s:\-|]*)\s*$"
147
+ )
148
+ _MARKUP_PREFIX_RE = re.compile(
149
+ r"^\s*(?:#{1,6}\s+|>\s?|[-*+]\s+|\d{1,3}[.)]\s+)"
150
+ )
151
+
152
+ CHANGE_RATE_WARN = 0.30
153
+ CHANGE_RATE_ABORT = 0.50
154
+ _CHANGE_RATE_EXACT_LIMIT = 2_000
155
+ _CHANGE_RATE_CHUNK_SIZE = 64
156
+
157
+
158
+ def _main(argv: Sequence[str] | None = None) -> int:
159
+ parser = argparse.ArgumentParser(description="Humanize Korean metric runner")
160
+ input_group = parser.add_mutually_exclusive_group(required=False)
161
+ input_group.add_argument("--input", help="Input text file path")
162
+ input_group.add_argument("--text", help="Raw input text; newlines are allowed")
163
+ parser.add_argument("--before", help="Original text file for change-rate comparison")
164
+ parser.add_argument("--after", help="Rewritten text file for change-rate comparison")
165
+ parser.add_argument(
166
+ "--ignore-markup",
167
+ action="store_true",
168
+ help="Ignore Markdown-only lines and prefixes during comparison",
169
+ )
170
+ parser.add_argument("--genre", default="essay", help="essay/report/blog/formal")
171
+ parser.add_argument("--output", default=None, help="Optional JSON output path")
172
+ args = parser.parse_args(argv)
173
+
174
+ if args.before is not None or args.after is not None:
175
+ if args.before is None or args.after is None:
176
+ parser.error("--before and --after must be supplied together")
177
+ if args.input is not None or args.text is not None:
178
+ parser.error("comparison mode cannot be combined with --input or --text")
179
+ if args.output is not None:
180
+ parser.error("--output is only available for metric mode")
181
+ before = read_text_file(args.before)
182
+ after = read_text_file(args.after)
183
+ print(f"{change_rate(before, after, ignore_markup=args.ignore_markup):.6f}")
184
+ return 0
185
+
186
+ if args.input is None and args.text is None:
187
+ parser.error("one of --input, --text, or --before/--after is required")
188
+
189
+ text = read_cli_text(args)
190
+ result = compute_all(text, genre=args.genre)
191
+
192
+ if args.output:
193
+ with open(args.output, "w", encoding="utf-8") as output_file:
194
+ json.dump(result, output_file, ensure_ascii=False, indent=2)
195
+
196
+ print(result["risk_band"])
197
+ return 0
198
+
199
+
200
+ def read_cli_text(args: argparse.Namespace) -> str:
201
+ """Return text from either a file path or a raw CLI argument."""
202
+ if args.text is not None:
203
+ return args.text
204
+
205
+ return read_text_file(args.input)
206
+
207
+
208
+ def read_text_file(path: str) -> str:
209
+ """Read UTF-8 text from a path supplied to the CLI."""
210
+ with open(path, "r", encoding="utf-8") as input_file:
211
+ return input_file.read()
212
+
213
+
214
+ def compute_all(text: str, genre: str = "essay") -> dict[str, Any]:
215
+ """Compute all supported metrics and a heuristic risk band."""
216
+ metrics = {
217
+ "comma_inclusion_rate": comma_inclusion_rate(text),
218
+ "comma_usage_rate": comma_usage_rate(text),
219
+ "ending_comma_rate": ending_comma_rate(text),
220
+ "comma_segment_length": comma_segment_length(text),
221
+ "conclusion_pivot_count": conclusion_pivot_count(text),
222
+ "signature_phrase_count": signature_phrase_count(text),
223
+ "safe_balance_count": safe_balance_count(text),
224
+ "hanja_nominalizer_density": hanja_nominalizer_density(text),
225
+ "lexical_diversity": lexical_diversity(text),
226
+ "lexical_density": lexical_density(text),
227
+ "ending_diversity": ending_diversity(text),
228
+ "normalisation_score": normalisation_score(text),
229
+ "da_streak_count": da_streak_count(text),
230
+ "inanimate_subject_rate": inanimate_subject_rate(text),
231
+ "by_passive_count": by_passive_count(text),
232
+ "double_passive_count": double_passive_count(text),
233
+ "pronoun_density": pronoun_density(text),
234
+ "deul_overuse_rate": deul_overuse_rate(text),
235
+ "relative_clause_nesting_count": relative_clause_nesting_count(text),
236
+ "have_make_literal_count": have_make_literal_count(text),
237
+ "double_particle_count": double_particle_count(text),
238
+ "progressive_aspect_rate": progressive_aspect_rate(text),
239
+ "antithesis_count": antithesis_count(text),
240
+ "clear_predicate_count": clear_predicate_count(text),
241
+ "not_longer_count": not_longer_count(text),
242
+ "foundation_metaphor_count": foundation_metaphor_count(text),
243
+ "conceptual_metaphor_count": conceptual_metaphor_count(text),
244
+ }
245
+ interference = interference_index(text, metrics)
246
+ risk_band, risk_score = classify_risk(metrics, interference)
247
+
248
+ return {
249
+ "version": VERSION,
250
+ "genre": genre,
251
+ "char_count": len(text),
252
+ "sentence_count": len(split_sentences(text)),
253
+ "metrics": metrics,
254
+ "interference_index": interference,
255
+ "risk_band": risk_band,
256
+ "risk_score": risk_score,
257
+ "evidence": evidence(text),
258
+ }
259
+
260
+
261
+ def change_rate(before: str, after: str, ignore_markup: bool = False) -> float:
262
+ """Return deterministic changed-content rate between two texts.
263
+
264
+ Short inputs use a character-level diff whose denominator is the longer
265
+ input, so one-sided deletions are not diluted by a similarity-ratio
266
+ denominator. Long inputs use fixed-size chunks to keep pathological,
267
+ repetitive comparisons bounded and deterministic. This is a comparison
268
+ helper, not a quality verdict. The skill uses 30% as a warning threshold
269
+ and 50% as a rollback threshold. Markup can be ignored when heading/list
270
+ changes would otherwise dominate the reading.
271
+ """
272
+ if ignore_markup:
273
+ before = _strip_markup(before)
274
+ after = _strip_markup(after)
275
+ if not before and not after:
276
+ return 0.0
277
+ if max(len(before), len(after)) <= _CHANGE_RATE_EXACT_LIMIT:
278
+ opcodes = difflib.SequenceMatcher(
279
+ None, before, after, autojunk=False
280
+ ).get_opcodes()
281
+ changed = sum(
282
+ max(i2 - i1, j2 - j1)
283
+ for tag, i1, i2, j1, j2 in opcodes
284
+ if tag != "equal"
285
+ )
286
+ return changed / max(len(before), len(after))
287
+ return _chunked_change_rate(before, after)
288
+
289
+
290
+ def _chunked_change_rate(before: str, after: str) -> float:
291
+ """Return a bounded, conservative rate for long inputs."""
292
+ total = max(len(before), len(after))
293
+ changed = 0
294
+ for start in range(0, total, _CHANGE_RATE_CHUNK_SIZE):
295
+ before_chunk = before[start : start + _CHANGE_RATE_CHUNK_SIZE]
296
+ after_chunk = after[start : start + _CHANGE_RATE_CHUNK_SIZE]
297
+ if before_chunk != after_chunk:
298
+ changed += max(len(before_chunk), len(after_chunk))
299
+ return changed / total
300
+
301
+
302
+ def _strip_markup(text: str) -> str:
303
+ """Remove common Markdown decoration while preserving visible text."""
304
+ kept: list[str] = []
305
+ for line in text.splitlines():
306
+ if _MARKUP_ONLY_LINE_RE.match(line):
307
+ continue
308
+ kept.append(_MARKUP_PREFIX_RE.sub("", line))
309
+ return "\n".join(kept)
310
+
311
+
312
+ def comma_inclusion_rate(text: str) -> float:
313
+ """Return the ratio of sentences containing at least one comma."""
314
+ sentences = split_sentences(text)
315
+ if not sentences:
316
+ return 0.0
317
+ return sum(1 for sentence in sentences if "," in sentence) / len(sentences)
318
+
319
+
320
+ def comma_usage_rate(text: str) -> float:
321
+ """Return average comma count per sentence."""
322
+ sentences = split_sentences(text)
323
+ if not sentences:
324
+ return 0.0
325
+ return sum(sentence.count(",") for sentence in sentences) / len(sentences)
326
+
327
+
328
+ def ending_comma_rate(text: str) -> float:
329
+ """Return the ratio of connective endings immediately followed by a comma."""
330
+ endings = _ENDING_BOUNDARY_RE.findall(text)
331
+ if not endings:
332
+ return 0.0
333
+ return len(_ENDING_COMMA_RE.findall(text)) / len(endings)
334
+
335
+
336
+ def comma_segment_length(text: str) -> float:
337
+ """Return average eojeol count in comma-delimited segments."""
338
+ segment_lengths: list[int] = []
339
+ for sentence in split_sentences(text):
340
+ segments = sentence.split(",") if "," in sentence else [sentence]
341
+ for segment in segments:
342
+ tokens = eojeols(segment)
343
+ if tokens:
344
+ segment_lengths.append(len(tokens))
345
+ if not segment_lengths:
346
+ return 0.0
347
+ return sum(segment_lengths) / len(segment_lengths)
348
+
349
+
350
+ def conclusion_pivot_count(text: str) -> int:
351
+ """Count conclusion-pivot phrases."""
352
+ return count_lexicon(text, _CONCLUSION_PIVOTS)
353
+
354
+
355
+ def signature_phrase_count(text: str) -> int:
356
+ """Count signature phrases that need contextual review."""
357
+ return count_lexicon(text, _SIGNATURE_PHRASES)
358
+
359
+
360
+ def safe_balance_count(text: str) -> int:
361
+ """Count safe-balance hedge phrases."""
362
+ return count_lexicon(text, _SAFE_BALANCES)
363
+
364
+
365
+ def hanja_nominalizer_density(text: str) -> float:
366
+ """Return token-level density of 한자어 nominalizer-like endings."""
367
+ tokens = stripped_eojeols(text)
368
+ if not tokens:
369
+ return 0.0
370
+ hits = sum(
371
+ 1 for token in tokens if len(token) >= 2 and token[-1] in _HANJA_SUFFIXES
372
+ )
373
+ return hits / len(tokens)
374
+
375
+
376
+ def lexical_diversity(text: str) -> float:
377
+ """Return type-token ratio over eojeols."""
378
+ tokens = stripped_eojeols(text)
379
+ if not tokens:
380
+ return 0.0
381
+ return len(set(tokens)) / len(tokens)
382
+
383
+
384
+ def lexical_density(text: str) -> float:
385
+ """Return a standard-library proxy for content-word density."""
386
+ tokens = stripped_eojeols(text)
387
+ if not tokens:
388
+ return 0.0
389
+
390
+ content_endings = (
391
+ "한다",
392
+ "된다",
393
+ "이다",
394
+ "했다",
395
+ "였다",
396
+ "었다",
397
+ "답다",
398
+ "스럽다",
399
+ "롭다",
400
+ )
401
+ stopwords = {
402
+ "그리고",
403
+ "그러나",
404
+ "하지만",
405
+ "또한",
406
+ "또는",
407
+ "혹은",
408
+ "즉",
409
+ "이는",
410
+ "따라서",
411
+ }
412
+ hits = 0
413
+ for token in tokens:
414
+ if len(token) < 2 or token in stopwords:
415
+ continue
416
+ if token[-1] in _HANJA_SUFFIXES or token.endswith(content_endings):
417
+ hits += 1
418
+ return hits / len(tokens)
419
+
420
+
421
+ def ending_diversity(text: str) -> float:
422
+ """Return unique sentence-ending keys divided by sentence count."""
423
+ keys: list[str] = []
424
+ for sentence in split_sentences(text):
425
+ match = _ENDING_FINAL_RE.search(sentence)
426
+ if match:
427
+ keys.append(match.group(1))
428
+ if not keys:
429
+ return 0.0
430
+ return len(set(keys)) / len(keys)
431
+
432
+
433
+ def normalisation_score(text: str) -> float:
434
+ """Return concentration of canonical declarative endings."""
435
+ sentences = split_sentences(text)
436
+ if not sentences:
437
+ return 0.0
438
+ hits = 0
439
+ for sentence in sentences:
440
+ last = last_eojeol(sentence)
441
+ if last.endswith(_DECLARATIVE_ENDINGS):
442
+ hits += 1
443
+ return hits / len(sentences)
444
+
445
+
446
+ def da_streak_count(text: str) -> int:
447
+ """Count sentence runs where '-다' endings repeat four or more times."""
448
+ streaks = 0
449
+ current = 0
450
+ for sentence in split_sentences(text):
451
+ if last_eojeol(sentence).endswith("다"):
452
+ current += 1
453
+ continue
454
+ if current >= 4:
455
+ streaks += 1
456
+ current = 0
457
+ if current >= 4:
458
+ streaks += 1
459
+ return streaks
460
+
461
+
462
+ def inanimate_subject_rate(text: str) -> float:
463
+ """Return rate of sentences with abstract subjects and universal verbs."""
464
+ sentences = split_sentences(text)
465
+ if not sentences:
466
+ return 0.0
467
+
468
+ subjects = (
469
+ "연구",
470
+ "데이터",
471
+ "분석",
472
+ "결과",
473
+ "시스템",
474
+ "기술",
475
+ "사례",
476
+ "현상",
477
+ "이론",
478
+ "정책",
479
+ "보고서",
480
+ "AI",
481
+ "인공지능",
482
+ "모델",
483
+ "알고리즘",
484
+ "변화",
485
+ "위기",
486
+ "혁신",
487
+ )
488
+ verbs = (
489
+ "보여준다",
490
+ "보여줬다",
491
+ "시사한다",
492
+ "만든다",
493
+ "드러낸다",
494
+ "제시한다",
495
+ "나타낸다",
496
+ "증명한다",
497
+ "말해준다",
498
+ "의미한다",
499
+ "가져온다",
500
+ )
501
+ hits = 0
502
+ for sentence in sentences:
503
+ tokens = stripped_eojeols(sentence)
504
+ if not tokens:
505
+ continue
506
+ head = trim_subject_marker(tokens[0])
507
+ if (
508
+ head in subjects or (len(head) >= 2 and head[-1] in _HANJA_SUFFIXES)
509
+ ) and any(any(verb in token for verb in verbs) for token in tokens[1:]):
510
+ hits += 1
511
+ return hits / len(sentences)
512
+
513
+
514
+ def by_passive_count(text: str) -> int:
515
+ """Count '~에 의해' followed by passive-like verbs."""
516
+ return len(_BY_PASSIVE_RE.findall(text))
517
+
518
+
519
+ def double_passive_count(text: str) -> int:
520
+ """Count surface forms of double passive expressions."""
521
+ return count_lexicon(text, _DOUBLE_PASSIVE_TOKENS)
522
+
523
+
524
+ def pronoun_density(text: str) -> float:
525
+ """Return mean paragraph density of translated personal pronouns."""
526
+ densities: list[float] = []
527
+ for paragraph in split_paragraphs(text):
528
+ tokens = stripped_eojeols(paragraph)
529
+ if tokens:
530
+ densities.append(len(_PRONOUN_RE.findall(paragraph)) / len(tokens))
531
+ if not densities:
532
+ return 0.0
533
+ try:
534
+ return mean(densities)
535
+ except StatisticsError:
536
+ return 0.0
537
+
538
+
539
+ def deul_overuse_rate(text: str) -> float:
540
+ """Return density of inanimate or abstract nouns with mechanical '-들'."""
541
+ tokens = stripped_eojeols(text)
542
+ if not tokens:
543
+ return 0.0
544
+ hits = sum(1 for token in tokens if is_deul_overuse_token(token))
545
+ return hits / len(tokens)
546
+
547
+
548
+ def relative_clause_nesting_count(text: str) -> int:
549
+ """Count sentences with likely left-branching relative-clause nesting."""
550
+ nested = 0
551
+ adnominal = re.compile(r"[가-힣]+(?:ㄴ|는|ㄹ|던|한|된|할|될|온|간)\s+[가-힣]")
552
+ for sentence in split_sentences(text):
553
+ if len(adnominal.findall(sentence)) >= 3:
554
+ nested += 1
555
+ return nested
556
+
557
+
558
+ def have_make_literal_count(text: str) -> int:
559
+ """Count literal have/make light-verb constructions."""
560
+ return count_lexicon(text, _HAVE_MAKE_LITERAL_TOKENS)
561
+
562
+
563
+ def double_particle_count(text: str) -> int:
564
+ """Count double-particle forms while excluding simple '~의'."""
565
+ return len(_DOUBLE_PARTICLE_RE.findall(text))
566
+
567
+
568
+ def progressive_aspect_rate(text: str) -> float:
569
+ """Return '~고 있다' surface-form hits per sentence."""
570
+ sentences = split_sentences(text)
571
+ if not sentences:
572
+ return 0.0
573
+ return sum(len(_PROGRESSIVE_RE.findall(sentence)) for sentence in sentences) / len(
574
+ sentences
575
+ )
576
+
577
+
578
+ def antithesis_count(text: str) -> int:
579
+ """Count common negative-positive parallel constructions for review.
580
+
581
+ C-8 is not a verdict by itself: a writer may use this rhetoric naturally.
582
+ The value only helps the model inspect repeated or tightly clustered use.
583
+ """
584
+ return len(_ANTITHESIS_RE.findall(text))
585
+
586
+
587
+ def clear_predicate_count(text: str) -> int:
588
+ """Count ``명확하다/분명하다`` predicate overlays, excluding adverbs."""
589
+ return len(_CLEAR_PREDICATE_RE.findall(text))
590
+
591
+
592
+ def not_longer_count(text: str) -> int:
593
+ """Count likely ``더 이상 ... 않다/아니다`` change constructions."""
594
+ return len(_NOT_LONGER_RE.findall(text))
595
+
596
+
597
+ def foundation_metaphor_count(text: str) -> int:
598
+ """Count foundation/opening metaphors that need contextual review."""
599
+ return len(_FOUNDATION_METAPHOR_RE.findall(text))
600
+
601
+
602
+ def conceptual_metaphor_count(text: str) -> int:
603
+ """Count a conservative family of conceptual-metaphor surface forms."""
604
+ return len(conceptual_metaphor_hits(text))
605
+
606
+
607
+ def conceptual_metaphor_hits(text: str) -> list[str]:
608
+ """Return longest-first, non-overlapping conceptual-metaphor matches."""
609
+ occupied: list[tuple[int, int]] = []
610
+ matches: list[tuple[int, str]] = []
611
+ for term in sorted(_CONCEPTUAL_METAPHORS, key=len, reverse=True):
612
+ for match in re.finditer(re.escape(term), text):
613
+ start, end = match.span()
614
+ overlaps = any(
615
+ start < occupied_end and end > occupied_start
616
+ for occupied_start, occupied_end in occupied
617
+ )
618
+ if overlaps:
619
+ continue
620
+ occupied.append((start, end))
621
+ matches.append((start, term))
622
+ return [term for _, term in sorted(matches)]
623
+
624
+
625
+ def interference_index(
626
+ text: str, metrics: dict[str, float | int] | None = None
627
+ ) -> dict[str, Any]:
628
+ """Return weighted post-editese interference components."""
629
+ current_metrics = metrics or compute_all(text)["metrics"]
630
+ chars = max(len(text), 1)
631
+ components = {
632
+ "T1_inanimate_subject_rate": float(current_metrics["inanimate_subject_rate"]),
633
+ "T2a_by_passive_per_1k": float(current_metrics["by_passive_count"])
634
+ / chars
635
+ * 1000,
636
+ "T2b_double_passive_per_1k": float(current_metrics["double_passive_count"])
637
+ / chars
638
+ * 1000,
639
+ "T3_pronoun_density": float(current_metrics["pronoun_density"]),
640
+ "T4_deul_overuse_rate": float(current_metrics["deul_overuse_rate"]),
641
+ "T5_nested_clause_count": float(
642
+ current_metrics["relative_clause_nesting_count"]
643
+ ),
644
+ "T6_have_make_per_1k": float(current_metrics["have_make_literal_count"])
645
+ / chars
646
+ * 1000,
647
+ "T7_double_particle_per_1k": float(current_metrics["double_particle_count"])
648
+ / chars
649
+ * 1000,
650
+ "T8b_progressive_rate": float(current_metrics["progressive_aspect_rate"]),
651
+ }
652
+ weights = {
653
+ "T1_inanimate_subject_rate": 1.0,
654
+ "T2a_by_passive_per_1k": 0.2,
655
+ "T2b_double_passive_per_1k": 0.2,
656
+ # Upstream calibration found raw pronoun density is higher in some
657
+ # human Korean prose. Keep it observable, but never let it drive risk.
658
+ "T3_pronoun_density": 0.0,
659
+ "T4_deul_overuse_rate": 4.0,
660
+ "T5_nested_clause_count": 0.05,
661
+ "T6_have_make_per_1k": 0.2,
662
+ "T7_double_particle_per_1k": 0.5,
663
+ "T8b_progressive_rate": 1.0,
664
+ }
665
+ total = sum(
666
+ min(1.0, max(0.0, components[key] * weights[key])) for key in components
667
+ )
668
+ return {
669
+ "components": components,
670
+ "weighted_total": total,
671
+ "diagnostic_only": ["T3_pronoun_density"],
672
+ }
673
+
674
+
675
+ def classify_risk(
676
+ metrics: dict[str, float | int], interference: dict[str, Any]
677
+ ) -> tuple[str, int]:
678
+ """Classify metrics while requiring more than one independent signal.
679
+
680
+ Comma-related signals share one family because a writer's punctuation
681
+ habit can be highly individual. A high band therefore needs both a high
682
+ score and at least two families, rather than one unusually high metric.
683
+ """
684
+ punctuation = 0
685
+ if metrics["ending_comma_rate"] > 0.35:
686
+ punctuation += 2
687
+ if metrics["comma_inclusion_rate"] > 0.55:
688
+ punctuation += 1
689
+ punctuation = min(punctuation, 3)
690
+
691
+ rhetoric = 0
692
+ if metrics["conclusion_pivot_count"] >= 3:
693
+ rhetoric += 2
694
+ if metrics["safe_balance_count"] >= 3:
695
+ rhetoric += 1
696
+
697
+ lexical = 0
698
+ if metrics["hanja_nominalizer_density"] > 0.12:
699
+ lexical += 1
700
+ if metrics["normalisation_score"] > 0.7 or metrics["da_streak_count"] >= 1:
701
+ lexical += 1
702
+
703
+ translation = 0
704
+ if interference["weighted_total"] >= 2.0:
705
+ translation += 2
706
+ elif interference["weighted_total"] >= 1.0:
707
+ translation += 1
708
+
709
+ score = punctuation + rhetoric + lexical + translation
710
+ families = sum(
711
+ family > 0 for family in (punctuation, rhetoric, lexical, translation)
712
+ )
713
+
714
+ if score >= 6 and families >= 2:
715
+ return "high", score
716
+ if score >= 3:
717
+ return "medium", score
718
+ return "low", score
719
+
720
+
721
+ def evidence(text: str) -> dict[str, list[str]]:
722
+ """Return matched lexicon evidence that can be shown in summaries."""
723
+ return {
724
+ "conclusion_pivots": lexicon_hits(text, _CONCLUSION_PIVOTS),
725
+ "signature_phrases": lexicon_hits(text, _SIGNATURE_PHRASES),
726
+ "safe_balances": lexicon_hits(text, _SAFE_BALANCES),
727
+ "double_passives": lexicon_hits(text, _DOUBLE_PASSIVE_TOKENS),
728
+ "have_make_literals": lexicon_hits(text, _HAVE_MAKE_LITERAL_TOKENS),
729
+ "conceptual_metaphors": conceptual_metaphor_hits(text),
730
+ "foundation_metaphors": _FOUNDATION_METAPHOR_RE.findall(text),
731
+ }
732
+
733
+
734
+ def split_sentences(text: str) -> list[str]:
735
+ """Split Korean prose into approximate sentences."""
736
+ stripped = text.strip()
737
+ if not stripped:
738
+ return []
739
+ sentences: list[str] = []
740
+ for part in _SENTENCE_SPLIT_RE.split(stripped):
741
+ for line in part.splitlines():
742
+ line = line.strip()
743
+ if line:
744
+ sentences.append(line)
745
+ return sentences
746
+
747
+
748
+ def split_paragraphs(text: str) -> list[str]:
749
+ """Split text by blank-line paragraph boundaries."""
750
+ stripped = text.strip()
751
+ if not stripped:
752
+ return []
753
+ return [
754
+ paragraph.strip()
755
+ for paragraph in _PARAGRAPH_SPLIT_RE.split(stripped)
756
+ if paragraph.strip()
757
+ ]
758
+
759
+
760
+ def eojeols(text: str) -> list[str]:
761
+ """Split text into whitespace-separated eojeols."""
762
+ return [token for token in _EOJEOL_SPLIT_RE.split(text.strip()) if token]
763
+
764
+
765
+ def stripped_eojeols(text: str) -> list[str]:
766
+ """Return eojeols with surrounding punctuation removed."""
767
+ return [
768
+ token
769
+ for token in (_PUNCT_STRIP_RE.sub("", raw) for raw in eojeols(text))
770
+ if token
771
+ ]
772
+
773
+
774
+ def last_eojeol(sentence: str) -> str:
775
+ """Return the final punctuation-stripped eojeol in a sentence."""
776
+ tokens = stripped_eojeols(sentence)
777
+ return tokens[-1] if tokens else ""
778
+
779
+
780
+ def trim_subject_marker(token: str) -> str:
781
+ """Remove a short Korean subject/topic marker from one token."""
782
+ for marker in ("은", "는", "이", "가", "도"):
783
+ if token.endswith(marker) and len(token) > len(marker):
784
+ return token[: -len(marker)]
785
+ return token
786
+
787
+
788
+ def is_deul_overuse_token(token: str) -> bool:
789
+ """Return true for abstract '-들' tokens with optional 조사 attached."""
790
+ if token in _INANIMATE_DEUL_TOKENS:
791
+ return True
792
+ for base in _INANIMATE_DEUL_TOKENS:
793
+ if token.startswith(base) and 1 <= len(token) - len(base) <= 2:
794
+ return all("가" <= char <= "힣" for char in token[len(base) :])
795
+ return False
796
+
797
+
798
+ def count_lexicon(text: str, lexicon: Sequence[str]) -> int:
799
+ """Count every occurrence of the supplied lexicon items."""
800
+ return sum(text.count(item) for item in lexicon)
801
+
802
+
803
+ def lexicon_hits(text: str, lexicon: Sequence[str]) -> list[str]:
804
+ """Return lexicon items present in text, preserving lexicon order."""
805
+ return [item for item in lexicon if item in text]
806
+
807
+
808
+ if __name__ == "__main__":
809
+ sys.exit(_main())