kintsugi 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,66 @@
1
+ """Locale registry: region, script and register variants (data as code).
2
+
3
+ Each entry is a BCP-47-style code with its base language, ISO 15924 script, region,
4
+ and register (standard acrolect vs colloquial/creole/vernacular basilect). These are
5
+ red-team target locales; a model collapsing a variant to its base is exactly what the
6
+ attacks probe. The exhaustive MT-engine coverage catalogues live in docs, not here."""
7
+
8
+ LOCALES = [
9
+ {'code': 'pt-BR', 'name': 'Portuguese (Brazil)', 'base': 'pt', 'script': 'Latn', 'region': 'BR', 'register': 'standard', 'band': 'pt', 'note': ''},
10
+ {'code': 'pt-PT', 'name': 'Portuguese (Portugal)', 'base': 'pt', 'script': 'Latn', 'region': 'PT', 'register': 'standard', 'band': 'pt', 'note': ''},
11
+ {'code': 'pt-MO', 'name': 'Portuguese (Macau)', 'base': 'pt', 'script': 'Latn', 'region': 'MO', 'register': 'standard', 'band': 'pt', 'note': ''},
12
+ {'code': 'fr-FR', 'name': 'French (France)', 'base': 'fr', 'script': 'Latn', 'region': 'FR', 'register': 'standard', 'band': 'fr', 'note': ''},
13
+ {'code': 'fr-CA', 'name': 'French (Canada)', 'base': 'fr', 'script': 'Latn', 'region': 'CA', 'register': 'standard', 'band': 'fr', 'note': ''},
14
+ {'code': 'fr-CH', 'name': 'French (Switzerland)', 'base': 'fr', 'script': 'Latn', 'region': 'CH', 'register': 'standard', 'band': 'fr', 'note': ''},
15
+ {'code': 'fr-BE', 'name': 'French (Belgium)', 'base': 'fr', 'script': 'Latn', 'region': 'BE', 'register': 'standard', 'band': 'fr', 'note': ''},
16
+ {'code': 'de-DE', 'name': 'German (Germany)', 'base': 'de', 'script': 'Latn', 'region': 'DE', 'register': 'standard', 'band': 'de', 'note': ''},
17
+ {'code': 'de-AT', 'name': 'Austrian Standard German', 'base': 'de', 'script': 'Latn', 'region': 'AT', 'register': 'standard', 'band': 'de', 'note': ''},
18
+ {'code': 'de-CH', 'name': 'Swiss Standard German', 'base': 'de', 'script': 'Latn', 'region': 'CH', 'register': 'standard', 'band': 'de', 'note': 'written standard in Switzerland; distinct from spoken gsw'},
19
+ {'code': 'gsw', 'name': 'Swiss German (Schweizerdeutsch)', 'base': 'de', 'script': 'Latn', 'region': 'CH', 'register': 'vernacular', 'band': 'de', 'note': 'spoken Alemannic vernacular; models tend to collapse it to Standard German'},
20
+ {'code': 'es-ES', 'name': 'Spanish (Spain)', 'base': 'es', 'script': 'Latn', 'region': 'ES', 'register': 'standard', 'band': 'es', 'note': ''},
21
+ {'code': 'es-MX', 'name': 'Spanish (Mexico)', 'base': 'es', 'script': 'Latn', 'region': 'MX', 'register': 'standard', 'band': 'es', 'note': ''},
22
+ {'code': 'es-AR', 'name': 'Spanish (Argentina)', 'base': 'es', 'script': 'Latn', 'region': 'AR', 'register': 'standard', 'band': 'es', 'note': ''},
23
+ {'code': 'es-419', 'name': 'Spanish (Latin America)', 'base': 'es', 'script': 'Latn', 'region': '419', 'register': 'standard', 'band': 'es', 'note': ''},
24
+ {'code': 'es-US', 'name': 'Spanish (United States)', 'base': 'es', 'script': 'Latn', 'region': 'US', 'register': 'standard', 'band': 'es', 'note': ''},
25
+ {'code': 'en-US', 'name': 'English (United States)', 'base': 'en', 'script': 'Latn', 'region': 'US', 'register': 'standard', 'band': 'en', 'note': ''},
26
+ {'code': 'en-GB', 'name': 'English (United Kingdom)', 'base': 'en', 'script': 'Latn', 'region': 'GB', 'register': 'standard', 'band': 'en', 'note': ''},
27
+ {'code': 'en-AU', 'name': 'English (Australia)', 'base': 'en', 'script': 'Latn', 'region': 'AU', 'register': 'standard', 'band': 'en', 'note': ''},
28
+ {'code': 'en-CA', 'name': 'English (Canada)', 'base': 'en', 'script': 'Latn', 'region': 'CA', 'register': 'standard', 'band': 'en', 'note': ''},
29
+ {'code': 'en-IN', 'name': 'Indian Standard English', 'base': 'en', 'script': 'Latn', 'region': 'IN', 'register': 'standard', 'band': 'en', 'note': ''},
30
+ {'code': 'en-SG', 'name': 'Singapore Standard English', 'base': 'en', 'script': 'Latn', 'region': 'SG', 'register': 'standard', 'band': 'en', 'note': 'the acrolect; contrast with en-SG-x-singlish'},
31
+ {'code': 'en-SG-x-singlish', 'name': 'Singlish', 'base': 'en', 'script': 'Latn', 'region': 'SG', 'register': 'creole', 'band': 'en', 'note': "Singapore colloquial English creole; the basilect to en-SG's acrolect"},
32
+ {'code': 'en-IN-x-hinglish', 'name': 'Hinglish', 'base': 'en', 'script': 'Latn', 'region': 'IN', 'register': 'colloquial', 'band': 'en', 'note': 'Hindi-English code-mixing; romanised; contrast en-IN and hi-Deva'},
33
+ {'code': 'en-JM', 'name': 'Jamaican Standard English', 'base': 'en', 'script': 'Latn', 'region': 'JM', 'register': 'standard', 'band': 'en', 'note': ''},
34
+ {'code': 'jam', 'name': 'Jamaican Patois', 'base': 'en', 'script': 'Latn', 'region': 'JM', 'register': 'creole', 'band': 'en', 'note': 'English-lexified creole; the basilect to Jamaican Standard English'},
35
+ {'code': 'nl-NL', 'name': 'Dutch (Netherlands)', 'base': 'nl', 'script': 'Latn', 'region': 'NL', 'register': 'standard', 'band': 'nl', 'note': ''},
36
+ {'code': 'nl-BE', 'name': 'Dutch (Belgium / Flemish)', 'base': 'nl', 'script': 'Latn', 'region': 'BE', 'register': 'standard', 'band': 'nl', 'note': ''},
37
+ {'code': 'it-IT', 'name': 'Italian (Italy)', 'base': 'it', 'script': 'Latn', 'region': 'IT', 'register': 'standard', 'band': 'it', 'note': ''},
38
+ {'code': 'it-CH', 'name': 'Italian (Switzerland)', 'base': 'it', 'script': 'Latn', 'region': 'CH', 'register': 'standard', 'band': 'it', 'note': ''},
39
+ {'code': 'ar', 'name': 'Modern Standard Arabic', 'base': 'ar', 'script': 'Arab', 'region': None, 'register': 'standard', 'band': 'ar', 'note': 'MSA'},
40
+ {'code': 'ar-EG', 'name': 'Egyptian Arabic', 'base': 'ar', 'script': 'Arab', 'region': 'EG', 'register': 'vernacular', 'band': 'ar', 'note': 'spoken dialect; Arabic diglossia vs MSA'},
41
+ {'code': 'ar-MA', 'name': 'Moroccan Arabic (Darija)', 'base': 'ar', 'script': 'Arab', 'region': 'MA', 'register': 'vernacular', 'band': 'ar', 'note': 'spoken dialect; Arabic diglossia vs MSA'},
42
+ {'code': 'zh-Hans', 'name': 'Chinese (Simplified)', 'base': 'zh', 'script': 'Hans', 'region': None, 'register': 'standard', 'band': 'zh', 'note': ''},
43
+ {'code': 'zh-Hant', 'name': 'Chinese (Traditional)', 'base': 'zh', 'script': 'Hant', 'region': None, 'register': 'standard', 'band': 'zh-Hant', 'note': ''},
44
+ {'code': 'zh-CN', 'name': 'Chinese (Mainland, Simplified)', 'base': 'zh', 'script': 'Hans', 'region': 'CN', 'register': 'standard', 'band': 'zh', 'note': ''},
45
+ {'code': 'zh-SG', 'name': 'Chinese (Singapore, Simplified)', 'base': 'zh', 'script': 'Hans', 'region': 'SG', 'register': 'standard', 'band': 'zh', 'note': ''},
46
+ {'code': 'zh-TW', 'name': 'Chinese (Taiwan, Traditional)', 'base': 'zh', 'script': 'Hant', 'region': 'TW', 'register': 'standard', 'band': 'zh-Hant', 'note': ''},
47
+ {'code': 'zh-HK', 'name': 'Chinese (Hong Kong, Traditional)', 'base': 'zh', 'script': 'Hant', 'region': 'HK', 'register': 'standard', 'band': 'zh-Hant', 'note': 'written standard Chinese in HK; spoken form is Cantonese (yue)'},
48
+ {'code': 'zh-MO', 'name': 'Chinese (Macau, Traditional)', 'base': 'zh', 'script': 'Hant', 'region': 'MO', 'register': 'standard', 'band': 'zh-Hant', 'note': ''},
49
+ {'code': 'yue', 'name': 'Cantonese', 'base': 'yue', 'script': 'Hant', 'region': 'HK', 'register': 'vernacular', 'band': 'yue', 'note': 'spoken Cantonese; models tend to collapse it to Mandarin'},
50
+ {'code': 'hi-Deva', 'name': 'Hindi (Devanagari)', 'base': 'hi', 'script': 'Deva', 'region': 'IN', 'register': 'standard', 'band': 'hi', 'note': ''},
51
+ {'code': 'hi-Latn', 'name': 'Hindi (Latin / romanised)', 'base': 'hi', 'script': 'Latn', 'region': 'IN', 'register': 'colloquial', 'band': 'hi', 'note': 'romanised Hindi; overlaps Hinglish'},
52
+ {'code': 'sr-Cyrl', 'name': 'Serbian (Cyrillic)', 'base': 'sr', 'script': 'Cyrl', 'region': 'RS', 'register': 'standard', 'band': 'sr', 'note': ''},
53
+ {'code': 'sr-Latn', 'name': 'Serbian (Latin)', 'base': 'sr', 'script': 'Latn', 'region': 'RS', 'register': 'standard', 'band': 'sr', 'note': ''},
54
+ {'code': 'az-Latn', 'name': 'Azerbaijani (Latin)', 'base': 'az', 'script': 'Latn', 'region': 'AZ', 'register': 'standard', 'band': 'az', 'note': ''},
55
+ {'code': 'az-Cyrl', 'name': 'Azerbaijani (Cyrillic)', 'base': 'az', 'script': 'Cyrl', 'region': 'AZ', 'register': 'standard', 'band': 'az', 'note': ''},
56
+ {'code': 'uz-Latn', 'name': 'Uzbek (Latin)', 'base': 'uz', 'script': 'Latn', 'region': 'UZ', 'register': 'standard', 'band': 'uz', 'note': ''},
57
+ {'code': 'uz-Cyrl', 'name': 'Uzbek (Cyrillic)', 'base': 'uz', 'script': 'Cyrl', 'region': 'UZ', 'register': 'standard', 'band': 'uz', 'note': ''},
58
+ {'code': 'pa-Guru', 'name': 'Punjabi (Gurmukhi)', 'base': 'pa', 'script': 'Guru', 'region': 'IN', 'register': 'standard', 'band': 'pa', 'note': ''},
59
+ {'code': 'pa-Arab', 'name': 'Punjabi (Shahmukhi)', 'base': 'pa', 'script': 'Arab', 'region': 'PK', 'register': 'standard', 'band': 'pa', 'note': ''},
60
+ {'code': 'ms-Latn', 'name': 'Malay (Rumi / Latin)', 'base': 'ms', 'script': 'Latn', 'region': 'MY', 'register': 'standard', 'band': 'ms', 'note': ''},
61
+ {'code': 'ms-Arab', 'name': 'Malay (Jawi / Arabic)', 'base': 'ms', 'script': 'Arab', 'region': 'MY', 'register': 'standard', 'band': 'ms', 'note': ''},
62
+ {'code': 'kk-Cyrl', 'name': 'Kazakh (Cyrillic)', 'base': 'kk', 'script': 'Cyrl', 'region': 'KZ', 'register': 'standard', 'band': 'kk', 'note': ''},
63
+ {'code': 'kk-Latn', 'name': 'Kazakh (Latin)', 'base': 'kk', 'script': 'Latn', 'region': 'KZ', 'register': 'standard', 'band': 'kk', 'note': ''},
64
+ {'code': 'mn-Cyrl', 'name': 'Mongolian (Cyrillic)', 'base': 'mn', 'script': 'Cyrl', 'region': 'MN', 'register': 'standard', 'band': 'mn', 'note': ''},
65
+ {'code': 'mn-Mong', 'name': 'Mongolian (Traditional script)', 'base': 'mn', 'script': 'Mong', 'region': 'MN', 'register': 'standard', 'band': 'mn', 'note': ''},
66
+ ]
kintsugi/attacks.py ADDED
@@ -0,0 +1,75 @@
1
+ """Attack categories and seeds discovered by the red-team project.
2
+
3
+ 201 categories / 2822 seeds, loaded from ``data/attacks.json``. Each category
4
+ is either ``open-weights`` (found attacking local Ollama models) or ``frontier``
5
+ (found attacking a frontier API). Categories also carry the catastrophic/bad
6
+ rates actually observed in the runs, keyed by language pair.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Iterator, Optional
12
+
13
+ from ._data.attacks import CATEGORIES as _CATEGORIES
14
+
15
+
16
+ @dataclass
17
+ class Attack:
18
+ """One adversarial source string plus where it came from."""
19
+
20
+ source: str
21
+ category: str
22
+ origin: str = "open-weights"
23
+ lang_pair: Optional[str] = None
24
+ meta: dict[str, Any] = field(default_factory=dict)
25
+
26
+
27
+ def categories() -> list[dict[str, Any]]:
28
+ """All category records: name, description, origin, seeds, observed rates."""
29
+ return _CATEGORIES
30
+
31
+
32
+ def category_names() -> list[str]:
33
+ return [c["name"] for c in categories()]
34
+
35
+
36
+ def observed_rates() -> dict[str, dict[str, dict[str, int]]]:
37
+ """Per-category observed counts ``{category: {lang_pair: {n, catastrophic, bad}}}``.
38
+
39
+ Counts exclude rows polluted by judge rate-limit errors, so rates are honest.
40
+ """
41
+ return {c["name"]: c["observed"] for c in categories() if "observed" in c}
42
+
43
+
44
+ def load_attacks(
45
+ category: Optional[str] = None,
46
+ origin: Optional[str] = None,
47
+ lang_pair: Optional[str] = None,
48
+ limit_per_category: Optional[int] = None,
49
+ ) -> list[Attack]:
50
+ """Expand categories into individual :class:`Attack` seeds.
51
+
52
+ Filter by ``category`` (exact name), ``origin`` ('open-weights'/'frontier'),
53
+ or a category's own ``lang_pair`` tag; cap seeds per category with
54
+ ``limit_per_category``.
55
+ """
56
+ out: list[Attack] = []
57
+ for cat in categories():
58
+ if category is not None and cat["name"] != category:
59
+ continue
60
+ if origin is not None and cat.get("origin") != origin:
61
+ continue
62
+ clp = cat.get("lang_pair")
63
+ if lang_pair is not None and clp is not None and clp != lang_pair:
64
+ continue
65
+ seeds = cat["seeds"]
66
+ if limit_per_category is not None:
67
+ seeds = seeds[:limit_per_category]
68
+ for s in seeds:
69
+ out.append(Attack(source=s, category=cat["name"], origin=cat.get("origin", "open-weights"),
70
+ lang_pair=clp, meta={"description": cat.get("description", "")}))
71
+ return out
72
+
73
+
74
+ def iter_attacks(**kwargs: Any) -> Iterator[Attack]:
75
+ yield from load_attacks(**kwargs)
kintsugi/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """Command-line interface for kintsugi.
2
+
3
+ kintsugi list [--origin open-weights|frontier]
4
+ kintsugi list-framings
5
+ kintsugi attack --target ollama:qwen3.5:0.8b --lang en-de --category prompt_injection
6
+ kintsugi frequency --target ollama:qwen3.5:0.8b --direction en-ja --n 30
7
+ kintsugi transform zero_width "Do not take with alcohol"
8
+
9
+ A target (and optional judge) is ``kind:model`` where kind is ``ollama``, ``hf``
10
+ or ``anthropic``. For your own model, use the Python API with CallableProvider.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from typing import Optional
18
+
19
+ from . import frequency, locales, transforms
20
+ from .attacks import categories, load_attacks
21
+ from .engine import LANG_PAIRS, RedTeam
22
+ from .framings import framings
23
+ from .providers import AnthropicProvider, HuggingFaceProvider, OllamaProvider, Provider
24
+
25
+
26
+ def _make_provider(spec: Optional[str]) -> Optional[Provider]:
27
+ if not spec:
28
+ return None
29
+ kind, _, model = spec.partition(":")
30
+ if not model:
31
+ raise SystemExit(f"target must be kind:model, got {spec!r}")
32
+ if kind == "ollama":
33
+ return OllamaProvider(model)
34
+ if kind in ("hf", "huggingface"):
35
+ return HuggingFaceProvider(model)
36
+ if kind == "anthropic":
37
+ return AnthropicProvider(model)
38
+ raise SystemExit(f"unknown provider kind {kind!r} (use ollama, hf, or anthropic)")
39
+
40
+
41
+ def _cmd_list(args):
42
+ for c in categories():
43
+ if args.origin and c.get("origin") != args.origin:
44
+ continue
45
+ n = len(c["seeds"])
46
+ print(f"{c['name']:<48} {c.get('origin',''):<12} {n:>4} seeds {c['description'][:70]}")
47
+
48
+
49
+ def _cmd_list_framings(args):
50
+ for f in framings():
51
+ print(f"{f['name']:<40} batch {f.get('batch','?')} {f['description'][:80]}")
52
+
53
+
54
+ def _cmd_transform(args):
55
+ print(transforms.apply(args.name, args.text, seed=args.seed))
56
+
57
+
58
+ def _run(args, attacks):
59
+ target = _make_provider(args.target)
60
+ judge = _make_provider(args.judge)
61
+ rt = RedTeam(target=target, judge=judge, lang_pair=args.lang)
62
+ findings = rt.run(attacks, progress=not args.quiet)
63
+ summary = rt.summary(findings)
64
+ print(json.dumps(summary, indent=1, ensure_ascii=False))
65
+ if args.out:
66
+ with open(args.out, "w", encoding="utf-8") as fh:
67
+ for f in findings:
68
+ fh.write(json.dumps(f.to_dict(), ensure_ascii=False) + "\n")
69
+ print(f"wrote {len(findings)} findings to {args.out}", file=sys.stderr)
70
+
71
+
72
+ def _cmd_attack(args):
73
+ attacks = load_attacks(category=args.category, origin=args.origin,
74
+ limit_per_category=args.limit)
75
+ if not attacks:
76
+ raise SystemExit("no attacks matched those filters")
77
+ _run(args, attacks)
78
+
79
+
80
+ def _cmd_list_locales(args):
81
+ for x in locales.locales(register=args.register, script=args.script):
82
+ reg = x["region"] or "-"
83
+ print(f"{x['code']:<22} {x['script']:<5} {reg:<4} {x['register']:<11} band={x['band']:<8} {x['name']}")
84
+
85
+
86
+ def _cmd_frequency(args):
87
+ attacks = frequency.probes(lang=args.lang_of, band=args.band, n=args.n, direction=args.direction)
88
+ # run in the probe's own language, not the attack default: words go source -> English
89
+ if args.lang_of == "ja":
90
+ args.lang = args.direction
91
+ else:
92
+ base = locales.band_key(args.lang_of) if locales.is_locale(args.lang_of) else args.lang_of
93
+ args.lang = f"{base}-en"
94
+ _run(args, attacks)
95
+
96
+
97
+ def build_parser() -> argparse.ArgumentParser:
98
+ p = argparse.ArgumentParser(prog="kintsugi", description="Red-team machine translation.")
99
+ sub = p.add_subparsers(dest="cmd", required=True)
100
+
101
+ pl = sub.add_parser("list", help="list attack categories")
102
+ pl.add_argument("--origin", choices=["open-weights", "frontier"])
103
+ pl.set_defaults(func=_cmd_list)
104
+
105
+ pf = sub.add_parser("list-framings", help="list translator framings")
106
+ pf.set_defaults(func=_cmd_list_framings)
107
+
108
+ ploc = sub.add_parser("list-locales", help="list region/script/register locale targets")
109
+ ploc.add_argument("--register", choices=["standard", "colloquial", "creole", "vernacular"])
110
+ ploc.add_argument("--script")
111
+ ploc.set_defaults(func=_cmd_list_locales)
112
+
113
+ pt = sub.add_parser("transform", help="apply an adversarial transform to text")
114
+ pt.add_argument("name")
115
+ pt.add_argument("text")
116
+ pt.add_argument("--seed", type=int)
117
+ pt.set_defaults(func=_cmd_transform)
118
+
119
+ def add_run_args(sp):
120
+ sp.add_argument("--target", required=True, help="kind:model, e.g. ollama:qwen3.5:0.8b")
121
+ sp.add_argument("--judge", help="kind:model for the LLM judge (optional)")
122
+ sp.add_argument("--lang", default="en-de", choices=sorted(LANG_PAIRS), help="language pair")
123
+ sp.add_argument("--out", help="write findings JSONL here")
124
+ sp.add_argument("--quiet", action="store_true")
125
+
126
+ pa = sub.add_parser("attack", help="run attack categories against a target")
127
+ add_run_args(pa)
128
+ pa.add_argument("--category", help="single category name (default: all)")
129
+ pa.add_argument("--origin", choices=["open-weights", "frontier"])
130
+ pa.add_argument("--limit", type=int, help="max seeds per category")
131
+ pa.set_defaults(func=_cmd_attack)
132
+
133
+ pq = sub.add_parser("frequency", help="frequent vs rare word probes (by language or locale)")
134
+ add_run_args(pq)
135
+ pq.add_argument("--lang-of", default="ja", dest="lang_of",
136
+ help="frequency language or locale code (e.g. ja, de, zh-Hant, en-SG-x-singlish)")
137
+ pq.add_argument("--direction", default="en-ja", choices=["en-ja", "ja-en"],
138
+ help="Japanese only: gloss->ja or ja->gloss")
139
+ pq.add_argument("--band", default="rare", choices=["frequent", "rare"])
140
+ pq.add_argument("--n", type=int, default=30)
141
+ pq.set_defaults(func=_cmd_frequency)
142
+ return p
143
+
144
+
145
+ def main(argv: Optional[list[str]] = None) -> None:
146
+ args = build_parser().parse_args(argv)
147
+ args.func(args)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()