keybridge 1.0.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.
- keybridge/__init__.py +6 -0
- keybridge/__main__.py +3 -0
- keybridge/cli.py +329 -0
- keybridge/colab.py +79 -0
- keybridge/common.py +72 -0
- keybridge/config.py +36 -0
- keybridge/convert.py +54 -0
- keybridge/data.py +289 -0
- keybridge/evaluate.py +261 -0
- keybridge/keys.py +236 -0
- keybridge/language_profiles/cz.json.gz +0 -0
- keybridge/language_profiles/de.json.gz +0 -0
- keybridge/language_profiles/en.json.gz +0 -0
- keybridge/language_profiles/es.json.gz +0 -0
- keybridge/language_profiles/fr.json.gz +0 -0
- keybridge/language_profiles/hu.json.gz +0 -0
- keybridge/language_profiles/is.json.gz +0 -0
- keybridge/language_profiles/it.json.gz +0 -0
- keybridge/language_profiles/la.json.gz +0 -0
- keybridge/language_profiles/nl.json.gz +0 -0
- keybridge/language_profiles/pl.json.gz +0 -0
- keybridge/language_profiles/sl.json.gz +0 -0
- keybridge/language_profiles/sv.json.gz +0 -0
- keybridge/languages.py +169 -0
- keybridge/model.py +219 -0
- keybridge/pipeline.py +381 -0
- keybridge/registry.py +130 -0
- keybridge/scan.py +159 -0
- keybridge/transcription.py +30 -0
- keybridge-1.0.0.dist-info/METADATA +82 -0
- keybridge-1.0.0.dist-info/RECORD +36 -0
- keybridge-1.0.0.dist-info/WHEEL +5 -0
- keybridge-1.0.0.dist-info/entry_points.txt +2 -0
- keybridge-1.0.0.dist-info/licenses/LICENSE +28 -0
- keybridge-1.0.0.dist-info/licenses/LICENSE-CC-BY-4.0.txt +415 -0
- keybridge-1.0.0.dist-info/top_level.txt +1 -0
keybridge/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Shared-space decipherment of simple and homophonic substitution ciphers.
|
|
2
|
+
|
|
3
|
+
Train a model for a known key, read transcriptions with it, and test transcriptions
|
|
4
|
+
against a collection of models. See the README for the notebooks and the command line.
|
|
5
|
+
"""
|
|
6
|
+
__version__ = "1.0.0"
|
keybridge/__main__.py
ADDED
keybridge/cli.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Command-line interface: the same workflows as the notebooks, run on your own computer.
|
|
2
|
+
|
|
3
|
+
keybridge train --key my_key.tsv --format space --plaintext text1.txt text2.txt --name my_cipher
|
|
4
|
+
keybridge read --model my_cipher_model.pt --transcription letter.txt
|
|
5
|
+
keybridge scan --transcription letter.txt --format char --models all
|
|
6
|
+
keybridge convert --key old_key.tsv --transcription letter.txt (to the standard format)
|
|
7
|
+
keybridge models list | download
|
|
8
|
+
keybridge languages
|
|
9
|
+
keybridge evaluate --model my_cipher_model.pt --transcription letter.txt (research: F1 against the key)
|
|
10
|
+
keybridge index-entry my_cipher_model.pt --add-to index.json (for maintainers)
|
|
11
|
+
|
|
12
|
+
Run any command with --help for its options.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .common import StopHere, resolve_format, safe_name
|
|
22
|
+
from .config import PLAINTEXT_DATASETS
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_custom_edits(path):
|
|
26
|
+
if not path:
|
|
27
|
+
return None, None
|
|
28
|
+
spec = importlib.util.spec_from_file_location("custom_edits", path)
|
|
29
|
+
mod = importlib.util.module_from_spec(spec)
|
|
30
|
+
spec.loader.exec_module(mod)
|
|
31
|
+
return (getattr(mod, "custom_edit_training_units", None),
|
|
32
|
+
getattr(mod, "custom_edit_transcription_line", None))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_train(a):
|
|
36
|
+
from .data import (load_builtin_plaintext, load_plaintext_files, parse_replacements,
|
|
37
|
+
resolve_plaintext_language)
|
|
38
|
+
from .keys import load_key
|
|
39
|
+
from .pipeline import plaintext_chars_needed, TrainSettings, describe_fit, fit_figures, prepare_data, preview, read_lines, \
|
|
40
|
+
save_training_outputs, train, write_reading
|
|
41
|
+
from .transcription import read_transcription
|
|
42
|
+
from .model import load_model
|
|
43
|
+
|
|
44
|
+
fmt = resolve_format(a.format)
|
|
45
|
+
name = safe_name(a.name)
|
|
46
|
+
s = TrainSettings(project=name, symbol_format=fmt,
|
|
47
|
+
plaintext_source="own files" if a.plaintext else "English", plaintext_files=a.plaintext or [],
|
|
48
|
+
letter_replacements=a.replacements, word_spaces=a.word_spaces,
|
|
49
|
+
training_examples=a.examples, sequence_length=a.length, null_rate=a.null_rate,
|
|
50
|
+
double_rate=a.double_rate, rare_unit_rate=a.rare_rate,
|
|
51
|
+
search_trials=a.search_trials, seed=a.seed, max_epochs=a.max_epochs)
|
|
52
|
+
train_edit, line_edit = _load_custom_edits(a.custom_edits)
|
|
53
|
+
key = load_key(a.key, fmt)
|
|
54
|
+
print(key.summary())
|
|
55
|
+
for w in key.warnings:
|
|
56
|
+
print(" •", w)
|
|
57
|
+
reps = parse_replacements(a.replacements)
|
|
58
|
+
heldout = None
|
|
59
|
+
if a.plaintext:
|
|
60
|
+
docs = load_plaintext_files(a.plaintext, reps)
|
|
61
|
+
else:
|
|
62
|
+
# Built-in plaintext: English unless --language says otherwise.
|
|
63
|
+
lang = resolve_plaintext_language(a.language)
|
|
64
|
+
s.plaintext_source = lang
|
|
65
|
+
print(f"Using the built-in {lang} plaintext.")
|
|
66
|
+
train_chars, heldout_chars = plaintext_chars_needed(a.examples, a.length)
|
|
67
|
+
docs = load_builtin_plaintext(lang, reps, train_chars, "train")
|
|
68
|
+
heldout = load_builtin_plaintext(lang, reps, heldout_chars, "validation")
|
|
69
|
+
data = prepare_data(key, docs, s, custom_edit=train_edit, heldout_documents=heldout)
|
|
70
|
+
result = train(key, data, s)
|
|
71
|
+
out_dir = os.path.join(a.out, name)
|
|
72
|
+
model_path = save_training_outputs(out_dir, s, key, a.key, data, result)
|
|
73
|
+
print(f"\nSaved to {out_dir}")
|
|
74
|
+
if a.transcription:
|
|
75
|
+
model, ck = load_model(model_path)
|
|
76
|
+
lines = read_transcription(a.transcription, fmt, a.skip.split(","), line_edit, a.length)
|
|
77
|
+
readings = read_lines(model, ck, lines)
|
|
78
|
+
write_reading(out_dir, name, readings, fmt, f"Decipherment of {os.path.basename(a.transcription)}",
|
|
79
|
+
checkpoint=ck)
|
|
80
|
+
preview(readings)
|
|
81
|
+
describe_fit(fit_figures(readings, ck))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _registry(a):
|
|
85
|
+
from .registry import ModelRegistry
|
|
86
|
+
return ModelRegistry(a.models_from)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_read(a):
|
|
90
|
+
from .model import load_model
|
|
91
|
+
from .pipeline import describe_fit, fit_figures, preview, read_lines, write_reading
|
|
92
|
+
from .transcription import read_transcription
|
|
93
|
+
|
|
94
|
+
path = a.model if os.path.exists(a.model) else None
|
|
95
|
+
if path is None:
|
|
96
|
+
reg = _registry(a)
|
|
97
|
+
path = reg.model_path(reg.entries(ids=[a.model])[0])
|
|
98
|
+
model, ck = load_model(path)
|
|
99
|
+
fmt = resolve_format(a.format) if a.format else ck["settings"].get("symbol_format")
|
|
100
|
+
_, line_edit = _load_custom_edits(a.custom_edits)
|
|
101
|
+
lines = read_transcription(a.transcription, fmt, a.skip.split(","), line_edit,
|
|
102
|
+
ck["settings"].get("sequence_length"))
|
|
103
|
+
readings = read_lines(model, ck, lines)
|
|
104
|
+
name = safe_name(os.path.splitext(os.path.basename(a.transcription))[0])
|
|
105
|
+
files = write_reading(a.out, name, readings, fmt,
|
|
106
|
+
f"Decipherment of {os.path.basename(a.transcription)} with {os.path.basename(path)}",
|
|
107
|
+
checkpoint=ck)
|
|
108
|
+
print(f"Read {len(readings)} lines:\n")
|
|
109
|
+
preview(readings)
|
|
110
|
+
print()
|
|
111
|
+
describe_fit(fit_figures(readings, ck))
|
|
112
|
+
print("\nWritten:", *files, sep="\n ")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_scan(a):
|
|
116
|
+
from .scan import describe_ranking, scan, top_group, write_scan_outputs
|
|
117
|
+
from .transcription import read_transcription
|
|
118
|
+
|
|
119
|
+
fmt = resolve_format(a.format)
|
|
120
|
+
_, line_edit = _load_custom_edits(a.custom_edits)
|
|
121
|
+
lines = read_transcription(a.transcription, fmt, a.skip.split(","), line_edit, a.max_length)
|
|
122
|
+
entries, reg = [], None
|
|
123
|
+
if a.models and a.models != "none":
|
|
124
|
+
reg = _registry(a)
|
|
125
|
+
if a.models in ("all", "digits", "characters", "named"):
|
|
126
|
+
entries = reg.entries(symbol_type=None if a.models == "all" else a.models)
|
|
127
|
+
else:
|
|
128
|
+
entries = reg.entries(ids=[m.strip() for m in a.models.split(",")])
|
|
129
|
+
if not entries and not a.model_file:
|
|
130
|
+
raise StopHere("No models to test. Give your own with --model-file.")
|
|
131
|
+
print(f"Testing {len(entries) + len(a.model_file or [])} model(s) on {len(lines)} lines...")
|
|
132
|
+
langs = set(a.languages.split(",")) if a.languages else None
|
|
133
|
+
results = scan(lines, fmt, reg, entries, a.model_file or [], langs, a.min_coverage)
|
|
134
|
+
top = top_group(results, a.margin, a.max_close)
|
|
135
|
+
print()
|
|
136
|
+
describe_ranking(results, top)
|
|
137
|
+
name = safe_name(os.path.splitext(os.path.basename(a.transcription))[0])
|
|
138
|
+
all_files, _ = write_scan_outputs(a.out, name, results, top, os.path.basename(a.transcription), a.all_outputs)
|
|
139
|
+
print("\nWritten:", *all_files, sep="\n ")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_evaluate(a):
|
|
143
|
+
from .evaluate import describe, evaluate, write_evaluation
|
|
144
|
+
from .keys import load_key
|
|
145
|
+
from .model import load_model
|
|
146
|
+
from .transcription import read_transcription
|
|
147
|
+
|
|
148
|
+
path = a.model if os.path.exists(a.model) else None
|
|
149
|
+
if path is None:
|
|
150
|
+
reg = _registry(a)
|
|
151
|
+
path = reg.model_path(reg.entries(ids=[a.model])[0])
|
|
152
|
+
model, ck = load_model(path)
|
|
153
|
+
fmt = resolve_format(a.format) if a.format else ck["settings"].get("symbol_format")
|
|
154
|
+
gold_key = load_key(a.key) if a.key else None
|
|
155
|
+
_, line_edit = _load_custom_edits(a.custom_edits)
|
|
156
|
+
lines = read_transcription(a.transcription, fmt, a.skip.split(","), line_edit,
|
|
157
|
+
ck["settings"].get("sequence_length"))
|
|
158
|
+
exclude = [u.strip().upper() for u in a.exclude_units.split(",") if u.strip()] if a.exclude_units else []
|
|
159
|
+
result, units, per_line, readings = evaluate(model, ck, lines, gold_key, a.exclude_unknown, exclude,
|
|
160
|
+
a.bootstrap, a.seed)
|
|
161
|
+
describe(result, units)
|
|
162
|
+
name = safe_name(a.name or os.path.splitext(os.path.basename(a.transcription))[0])
|
|
163
|
+
files = write_evaluation(a.out, name, result, units, per_line, readings)
|
|
164
|
+
if a.reference_plaintext:
|
|
165
|
+
from .evaluate import (describe_reference_check, read_reference_plaintext, reference_check,
|
|
166
|
+
write_reference_check)
|
|
167
|
+
from .keys import CipherKey
|
|
168
|
+
ref_lines = read_reference_plaintext(a.reference_plaintext, a.skip.split(","))
|
|
169
|
+
summary, rows = reference_check(readings, gold_key or CipherKey.from_json(ck["key"]), ref_lines)
|
|
170
|
+
describe_reference_check(summary)
|
|
171
|
+
files += write_reference_check(a.out, name, summary, rows)
|
|
172
|
+
print("\nWritten:", *files, sep="\n ")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_convert(a):
|
|
176
|
+
from .convert import convert_key, convert_transcription
|
|
177
|
+
if not a.key and not a.transcription:
|
|
178
|
+
raise StopHere("Give --key and/or --transcription to convert.")
|
|
179
|
+
if a.key:
|
|
180
|
+
out, key = convert_key(a.key, a.key_out)
|
|
181
|
+
print(f"Key written in the standard format: {out}"
|
|
182
|
+
+ (' (blank symbol " " written as "_")' if key.blank_converted else ""))
|
|
183
|
+
for path in a.transcription or []:
|
|
184
|
+
out, n = convert_transcription(path, None, a.skip.split(","))
|
|
185
|
+
print(f"Transcription converted ({n} lines): {out}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_models(a):
|
|
189
|
+
reg = _registry(a)
|
|
190
|
+
entries = reg.entries()
|
|
191
|
+
if a.action == "list":
|
|
192
|
+
print(f"{len(entries)} model(s) in {reg.source}:")
|
|
193
|
+
for e in entries:
|
|
194
|
+
print(f" {e['id']:<28} {e.get('symbol_format', ''):<28} {e.get('symbol_type', ''):<10} {e.get('name', '')}")
|
|
195
|
+
else:
|
|
196
|
+
for e in entries:
|
|
197
|
+
print(" downloading", e["id"], "->", reg.model_path(e))
|
|
198
|
+
print("Done. To work offline from now on, set the environment variable HF_HUB_OFFLINE=1.")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_languages(a):
|
|
202
|
+
from .languages import available_languages
|
|
203
|
+
langs = available_languages()
|
|
204
|
+
if not langs:
|
|
205
|
+
print("No language profiles installed.")
|
|
206
|
+
for code, (name, path) in sorted(langs.items()):
|
|
207
|
+
print(f" {code:<4} {name:<12} {path}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_index_entry(a):
|
|
211
|
+
from .registry import make_index_entry, new_index
|
|
212
|
+
entry = make_index_entry(a.model, a.id)
|
|
213
|
+
if a.add_to:
|
|
214
|
+
index = new_index()
|
|
215
|
+
if os.path.exists(a.add_to):
|
|
216
|
+
with open(a.add_to, encoding="utf-8") as f:
|
|
217
|
+
index = json.load(f)
|
|
218
|
+
index["models"] = [m for m in index["models"] if m["id"] != entry["id"]] + [entry]
|
|
219
|
+
with open(a.add_to, "w", encoding="utf-8") as f:
|
|
220
|
+
json.dump(index, f, ensure_ascii=False, indent=2)
|
|
221
|
+
print(f"Added '{entry['id']}' to {a.add_to}. Complete the fields marked FILL IN.")
|
|
222
|
+
else:
|
|
223
|
+
print(json.dumps(entry, ensure_ascii=False, indent=2))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv=None):
|
|
227
|
+
p = argparse.ArgumentParser(prog="keybridge", description=__doc__,
|
|
228
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
229
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
230
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
231
|
+
fmt_help = "transcription format: 'space' (symbols separated by spaces) or 'char' (one character per symbol)"
|
|
232
|
+
|
|
233
|
+
t = sub.add_parser("train", help="train a model for a key")
|
|
234
|
+
t.add_argument("--key", required=True, help="key file (.tsv)")
|
|
235
|
+
t.add_argument("--format", required=True, help=fmt_help)
|
|
236
|
+
t.add_argument("--plaintext", nargs="+", help="your own plaintext .txt file(s); without this, built-in plaintext is used")
|
|
237
|
+
t.add_argument("--language", default="English",
|
|
238
|
+
help="language of the built-in plaintext (default English): " + ", ".join(PLAINTEXT_DATASETS))
|
|
239
|
+
t.add_argument("--name", default="my_cipher")
|
|
240
|
+
t.add_argument("--replacements", default="", help="letter replacements, e.g. 'J=I, V=U'")
|
|
241
|
+
t.add_argument("--word-spaces", default="keep real word boundaries",
|
|
242
|
+
choices=["keep real word boundaries", "insert at random", "remove"])
|
|
243
|
+
t.add_argument("--examples", type=int, default=5000, help="number of synthetic training examples (default 5000)")
|
|
244
|
+
t.add_argument("--length", type=int, default=50)
|
|
245
|
+
t.add_argument("--null-rate", type=float, default=0.05)
|
|
246
|
+
t.add_argument("--double-rate", type=float, default=0.5)
|
|
247
|
+
t.add_argument("--rare-rate", type=float, default=0.02,
|
|
248
|
+
help="insert key units that are rare or missing in the plaintext at this rate per word gap (default 0.02; 0 = off)")
|
|
249
|
+
t.add_argument("--search-trials", type=int, default=20,
|
|
250
|
+
help="short trials of the model-settings search (default 20; 0 = skip the search and use the fixed default settings)")
|
|
251
|
+
t.add_argument("--seed", type=int, default=9)
|
|
252
|
+
t.add_argument("--max-epochs", type=int, default=100, help="upper limit on training rounds")
|
|
253
|
+
t.add_argument("--transcription", help="also read this transcription after training")
|
|
254
|
+
t.add_argument("--skip", default="##", help="skip transcription lines starting with these (comma-separated)")
|
|
255
|
+
t.add_argument("--custom-edits", help="Python file defining custom_edit_training_units / custom_edit_transcription_line")
|
|
256
|
+
t.add_argument("--out", default="outputs")
|
|
257
|
+
t.set_defaults(func=cmd_train)
|
|
258
|
+
|
|
259
|
+
for name, func, helptext in (("read", cmd_read, "read a transcription with one model"),
|
|
260
|
+
("scan", cmd_scan, "test a transcription against many models")):
|
|
261
|
+
r = sub.add_parser(name, help=helptext)
|
|
262
|
+
r.add_argument("--transcription", required=True)
|
|
263
|
+
r.add_argument("--skip", default="##")
|
|
264
|
+
r.add_argument("--custom-edits")
|
|
265
|
+
r.add_argument("--out", default="outputs")
|
|
266
|
+
r.add_argument("--models-from", help="Hugging Face repo id or local folder of the model collection")
|
|
267
|
+
r.set_defaults(func=func)
|
|
268
|
+
if name == "read":
|
|
269
|
+
r.add_argument("--model", required=True, help="model file (.pt) or model ID from the collection")
|
|
270
|
+
r.add_argument("--format", help=fmt_help + " (default: the model's own)")
|
|
271
|
+
else:
|
|
272
|
+
r.add_argument("--format", required=True, help=fmt_help)
|
|
273
|
+
r.add_argument("--models", default="all",
|
|
274
|
+
help="'all', 'digits', 'characters', 'named', comma-separated model IDs, or 'none'")
|
|
275
|
+
r.add_argument("--model-file", action="append", help="your own model file (repeatable)")
|
|
276
|
+
r.add_argument("--languages", help="limit language scoring to these codes, e.g. de,la,it")
|
|
277
|
+
r.add_argument("--min-coverage", type=float, default=0.75)
|
|
278
|
+
r.add_argument("--margin", type=float, default=0.05)
|
|
279
|
+
r.add_argument("--max-close", type=int, default=3)
|
|
280
|
+
r.add_argument("--max-length", type=int, default=50, help="longer lines are read in pieces")
|
|
281
|
+
r.add_argument("--all-outputs", action="store_true", help="write decipherments for every model")
|
|
282
|
+
|
|
283
|
+
ev = sub.add_parser("evaluate", help="(research) score a model's reading against the key")
|
|
284
|
+
ev.add_argument("--model", required=True, help="model file (.pt) or model ID from the collection")
|
|
285
|
+
ev.add_argument("--transcription", required=True)
|
|
286
|
+
ev.add_argument("--format", help=fmt_help + " (default: the model's own)")
|
|
287
|
+
ev.add_argument("--key", help="key to derive the reference from (default: the key stored in the model)")
|
|
288
|
+
ev.add_argument("--reference-plaintext", help="the archive's plaintext transcription, line by line, to check "
|
|
289
|
+
"the key-derived reference against")
|
|
290
|
+
ev.add_argument("--skip", default="##")
|
|
291
|
+
ev.add_argument("--custom-edits", help="Python file defining custom_edit_transcription_line")
|
|
292
|
+
ev.add_argument("--exclude-unknown", action="store_true", help="do not score symbols that are not in the key")
|
|
293
|
+
ev.add_argument("--exclude-units", help="comma-separated plaintext units not to score, e.g. 'MASTER,LODGE'")
|
|
294
|
+
ev.add_argument("--bootstrap", type=int, default=1000, help="line-bootstrap resamples for CIs (0: none)")
|
|
295
|
+
ev.add_argument("--seed", type=int, default=0)
|
|
296
|
+
ev.add_argument("--name", help="name for the output files (default: transcription file name)")
|
|
297
|
+
ev.add_argument("--models-from")
|
|
298
|
+
ev.add_argument("--out", default="outputs")
|
|
299
|
+
ev.set_defaults(func=cmd_evaluate)
|
|
300
|
+
|
|
301
|
+
cv = sub.add_parser("convert", help="convert a key and/or 'one character per symbol' transcriptions "
|
|
302
|
+
"to the standard format")
|
|
303
|
+
cv.add_argument("--key", help="key file to rewrite in the standard format")
|
|
304
|
+
cv.add_argument("--key-out", help="output file for the key (default: <name>_standard.tsv)")
|
|
305
|
+
cv.add_argument("--transcription", nargs="+", help="'one character per symbol' transcription(s) to convert "
|
|
306
|
+
"(each written to <name>_standard.txt)")
|
|
307
|
+
cv.add_argument("--skip", default="##", help="note lines starting with these are copied unchanged")
|
|
308
|
+
cv.set_defaults(func=cmd_convert)
|
|
309
|
+
|
|
310
|
+
m = sub.add_parser("models", help="list or download the shared models")
|
|
311
|
+
m.add_argument("action", choices=["list", "download"])
|
|
312
|
+
m.add_argument("--models-from")
|
|
313
|
+
m.set_defaults(func=cmd_models)
|
|
314
|
+
|
|
315
|
+
sub.add_parser("languages", help="list installed language profiles").set_defaults(func=cmd_languages)
|
|
316
|
+
|
|
317
|
+
ie = sub.add_parser("index-entry", help="(maintainers) make an index.json entry for a model")
|
|
318
|
+
ie.add_argument("model")
|
|
319
|
+
ie.add_argument("--id")
|
|
320
|
+
ie.add_argument("--add-to", help="index.json to add the entry to (created if missing)")
|
|
321
|
+
ie.set_defaults(func=cmd_index_entry)
|
|
322
|
+
|
|
323
|
+
a = p.parse_args(argv)
|
|
324
|
+
try:
|
|
325
|
+
a.func(a)
|
|
326
|
+
except StopHere as e:
|
|
327
|
+
print(str(e), file=sys.stderr)
|
|
328
|
+
sys.exit(2)
|
|
329
|
+
|
keybridge/colab.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Helpers for the Colab notebooks (uploads, downloads, Google Drive). They also work
|
|
2
|
+
in a local Jupyter notebook, where uploads are replaced by file paths."""
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import zipfile
|
|
6
|
+
|
|
7
|
+
from .common import stop
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def in_colab():
|
|
11
|
+
try:
|
|
12
|
+
import google.colab # noqa: F401
|
|
13
|
+
return True
|
|
14
|
+
except ImportError:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_or_upload(value, what, upload_dir, multiple=False, required=True):
|
|
19
|
+
"""Returns path(s) given in `value` (comma-separated paths or folders), or asks for an upload."""
|
|
20
|
+
os.makedirs(upload_dir, exist_ok=True)
|
|
21
|
+
paths = [p.strip() for p in (value or "").split(",") if p.strip()]
|
|
22
|
+
found = []
|
|
23
|
+
for p in paths:
|
|
24
|
+
if os.path.isdir(p):
|
|
25
|
+
found += [os.path.join(p, f) for f in sorted(os.listdir(p)) if not f.startswith(".")]
|
|
26
|
+
elif os.path.exists(p):
|
|
27
|
+
found.append(p)
|
|
28
|
+
elif os.path.exists(os.path.join(upload_dir, p)):
|
|
29
|
+
found.append(os.path.join(upload_dir, p))
|
|
30
|
+
else:
|
|
31
|
+
stop(f"The {what} could not be found at: {p}\nLeave the box empty to upload it instead, or check the path.")
|
|
32
|
+
if found:
|
|
33
|
+
return found if multiple else found[0]
|
|
34
|
+
if not in_colab():
|
|
35
|
+
if required:
|
|
36
|
+
stop(f"Please give the path to your {what} in step ①.")
|
|
37
|
+
return [] if multiple else None
|
|
38
|
+
from google.colab import files
|
|
39
|
+
print(f"Please upload your {what}" + (" (you may select several files)" if multiple else "") + ":")
|
|
40
|
+
uploaded = files.upload()
|
|
41
|
+
if not uploaded:
|
|
42
|
+
if required:
|
|
43
|
+
stop(f"No {what} was uploaded. Run this step again to try once more.")
|
|
44
|
+
return [] if multiple else None
|
|
45
|
+
saved = []
|
|
46
|
+
for name, data in uploaded.items():
|
|
47
|
+
dest = os.path.join(upload_dir, name)
|
|
48
|
+
with open(dest, "wb") as f:
|
|
49
|
+
f.write(data)
|
|
50
|
+
saved.append(dest)
|
|
51
|
+
return saved if multiple else saved[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def zip_files(paths, zip_path, folder_name):
|
|
55
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
|
|
56
|
+
for p in paths:
|
|
57
|
+
z.write(p, arcname=f"{folder_name}/{os.path.basename(p)}")
|
|
58
|
+
return zip_path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def offer_download(path):
|
|
62
|
+
if in_colab():
|
|
63
|
+
from google.colab import files
|
|
64
|
+
print(f"Downloading {os.path.basename(path)} ... (if nothing happens: open the folder icon on the left, "
|
|
65
|
+
"find the file, right-click → Download)")
|
|
66
|
+
files.download(path)
|
|
67
|
+
else:
|
|
68
|
+
print("Saved:", path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def copy_to_drive(folder, subfolder):
|
|
72
|
+
if not in_colab():
|
|
73
|
+
return None
|
|
74
|
+
from google.colab import drive
|
|
75
|
+
drive.mount("/content/drive")
|
|
76
|
+
dest = f"/content/drive/MyDrive/{subfolder}"
|
|
77
|
+
shutil.copytree(folder, dest, dirs_exist_ok=True)
|
|
78
|
+
print(f"Copied to your Google Drive: My Drive/{subfolder}")
|
|
79
|
+
return dest
|
keybridge/common.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared constants, the friendly error type and small file helpers."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
# Special tokens shared by both vocabularies (same index in both).
|
|
5
|
+
PAD, SOS, EOS, UNK = "<PAD>", "<SOS>", "<EOS>", "<UNK>"
|
|
6
|
+
SPECIAL_TOKENS = [PAD, SOS, EOS, UNK]
|
|
7
|
+
|
|
8
|
+
# Special plaintext units a key may use.
|
|
9
|
+
SPACE_UNIT = " " # <SPACE> in a key: the gap between words
|
|
10
|
+
NULL_UNIT = "<NULL>" # meaningless filler symbols
|
|
11
|
+
DOUBLE_UNIT = "<DOUBLE>" # "repeat the previous letter" sign
|
|
12
|
+
SPECIAL_UNIT_NAMES = {"<SPACE>": SPACE_UNIT, "<NULL>": NULL_UNIT, "<DOUBLE>": DOUBLE_UNIT}
|
|
13
|
+
SPECIAL_UNITS = set(SPECIAL_UNIT_NAMES.values())
|
|
14
|
+
|
|
15
|
+
# A blank space used as a cipher symbol (e.g. a word divider) is always written as this
|
|
16
|
+
# visible symbol, in keys, transcriptions and outputs, so that every key and transcription
|
|
17
|
+
# can use the standard "symbols separated by spaces" format.
|
|
18
|
+
BLANK_SYMBOL = "_"
|
|
19
|
+
|
|
20
|
+
# Transcription formats. "symbols separated by spaces" is the standard; "one character per
|
|
21
|
+
# symbol" files are read character by character, with blanks turned into BLANK_SYMBOL.
|
|
22
|
+
SPACE_SEPARATED = "symbols separated by spaces"
|
|
23
|
+
ONE_CHAR = "one character per symbol"
|
|
24
|
+
FORMATS = (SPACE_SEPARATED, ONE_CHAR)
|
|
25
|
+
FORMAT_ALIASES = {"space": SPACE_SEPARATED, "spaces": SPACE_SEPARATED, "char": ONE_CHAR,
|
|
26
|
+
"chars": ONE_CHAR, "character": ONE_CHAR}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_format(name):
|
|
30
|
+
name = FORMAT_ALIASES.get(name, name)
|
|
31
|
+
if name not in FORMATS:
|
|
32
|
+
stop(f"Unknown transcription format {name!r}. Use 'space' ({SPACE_SEPARATED}) "
|
|
33
|
+
f"or 'char' ({ONE_CHAR}).")
|
|
34
|
+
return name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StopHere(Exception):
|
|
38
|
+
"""Raised with a plain-language message when something needs fixing."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def stop(message):
|
|
42
|
+
raise StopHere("\n\n" + message.strip() + "\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_text_file(path):
|
|
46
|
+
with open(path, "rb") as f:
|
|
47
|
+
raw = f.read()
|
|
48
|
+
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
49
|
+
return raw.decode("utf-16")
|
|
50
|
+
for enc in ("utf-8-sig", "cp1252"):
|
|
51
|
+
try:
|
|
52
|
+
return raw.decode(enc)
|
|
53
|
+
except UnicodeDecodeError:
|
|
54
|
+
continue
|
|
55
|
+
return raw.decode("latin-1")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def split_symbols(text, symbol_format):
|
|
59
|
+
"""Split one transcription line into cipher symbols. In 'one character per symbol' text,
|
|
60
|
+
a blank is a symbol and becomes BLANK_SYMBOL."""
|
|
61
|
+
if symbol_format == SPACE_SEPARATED:
|
|
62
|
+
return text.split()
|
|
63
|
+
return [BLANK_SYMBOL if c == " " else c for c in text]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def safe_name(text, default="my_cipher"):
|
|
67
|
+
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "").strip()).strip("_") or default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def say(log, *parts):
|
|
71
|
+
if log:
|
|
72
|
+
log(" ".join(str(p) for p in parts))
|
keybridge/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Names and addresses that the maintainer sets once.
|
|
2
|
+
|
|
3
|
+
Every value marked PLACEHOLDER must be replaced when the GitHub and Hugging Face
|
|
4
|
+
repositories exist. See docs/MAINTAINER_SETUP.md for the full list.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# GitHub repository holding this code (owner/name).
|
|
8
|
+
GITHUB_REPO = "mbruton0426/keybridge"
|
|
9
|
+
|
|
10
|
+
# Hugging Face model repository holding index.json and one folder per key (owner/name).
|
|
11
|
+
# A local folder with the same layout also works (for testing, or fully offline use).
|
|
12
|
+
HF_MODEL_REPO = "mbruton/keybridge-models"
|
|
13
|
+
|
|
14
|
+
# Where people send shared-model bundles by email (empty: no address is shown).
|
|
15
|
+
SHARE_CONTACT_EMAIL = 'micaella.bruton@ling.su.se'
|
|
16
|
+
|
|
17
|
+
# Built-in plaintext for training: one public Hugging Face dataset per language, each with
|
|
18
|
+
# "train" and "validation" splits and a "plaintext" column (historical text, HistCorp).
|
|
19
|
+
# Load them with datasets.load_dataset(repo); texts are ds["train"]["plaintext"] etc.
|
|
20
|
+
PLAINTEXT_DATASETS = {
|
|
21
|
+
"English": "mbruton/english_encrypted_HistCiph",
|
|
22
|
+
"Czech": "mbruton/czech_encrypted_HistCiph",
|
|
23
|
+
"Dutch": "mbruton/dutch_encrypted_HistCiph",
|
|
24
|
+
"French": "mbruton/french_encrypted_HistCiph",
|
|
25
|
+
"Hungarian": "mbruton/hungarian_encrypted_HistCiph",
|
|
26
|
+
"Icelandic": "mbruton/icelandic_encrypted_HistCiph",
|
|
27
|
+
"Italian": "mbruton/italian_encrypted_HistCiph",
|
|
28
|
+
"Polish": "mbruton/polish_encrypted_HistCiph",
|
|
29
|
+
"Spanish": "mbruton/spanish_encrypted_HistCiph",
|
|
30
|
+
"Swedish": "mbruton/swedish_encrypted_HistCiph",
|
|
31
|
+
}
|
|
32
|
+
DEFAULT_PLAINTEXT_LANGUAGE = "English"
|
|
33
|
+
PLAINTEXT_FIELD = "plaintext"
|
|
34
|
+
|
|
35
|
+
CITATION = "[citation to be added]" # PLACEHOLDER
|
|
36
|
+
LICENSE = "CC-BY-4.0" # licence of the models and keys in the collection (the code is MIT)
|
keybridge/convert.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Converting keys and transcriptions to the standard format.
|
|
2
|
+
|
|
3
|
+
Standard format: cipher symbols separated by spaces, and a blank space used as a cipher
|
|
4
|
+
symbol (e.g. a word divider) written as "_" (BLANK_SYMBOL).
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from .common import ONE_CHAR, read_text_file, split_symbols
|
|
9
|
+
from .keys import load_key
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def standard_path(path, ext=None):
|
|
13
|
+
stem, old_ext = os.path.splitext(path)
|
|
14
|
+
return f"{stem}_standard{ext or old_ext}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def convert_transcription(path, out_path=None, skip_prefixes=("##",)):
|
|
18
|
+
"""Converts a 'one character per symbol' transcription: every character becomes a symbol,
|
|
19
|
+
separated by spaces, with blanks written as '_'. Empty lines and note lines (starting with
|
|
20
|
+
a skip marker) are copied unchanged. Returns (output path, lines converted)."""
|
|
21
|
+
out_path = out_path or standard_path(path)
|
|
22
|
+
out, converted = [], 0
|
|
23
|
+
for raw in read_text_file(path).splitlines():
|
|
24
|
+
line = raw.rstrip("\r\n")
|
|
25
|
+
if not line.strip() or any(line.lstrip().startswith(p) for p in skip_prefixes if p):
|
|
26
|
+
out.append(line)
|
|
27
|
+
continue
|
|
28
|
+
out.append(" ".join(split_symbols(line.strip(), ONE_CHAR)))
|
|
29
|
+
converted += 1
|
|
30
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
31
|
+
f.write("\n".join(out) + "\n")
|
|
32
|
+
return out_path, converted
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def convert_key(path, out_path=None):
|
|
36
|
+
"""Rewrites a key (.tsv or .json) in the standard .tsv format, with a blank symbol written
|
|
37
|
+
as '_'. Note lines at the top of a .tsv key are kept. Returns (output path, key)."""
|
|
38
|
+
out_path = out_path or standard_path(path, ".tsv")
|
|
39
|
+
key = load_key(path)
|
|
40
|
+
notes = []
|
|
41
|
+
if path.lower().endswith((".tsv", ".txt")):
|
|
42
|
+
for line in read_text_file(path).splitlines():
|
|
43
|
+
if line.lstrip().startswith("#"):
|
|
44
|
+
notes.append(line)
|
|
45
|
+
elif line.strip():
|
|
46
|
+
break
|
|
47
|
+
key.write_tsv(out_path)
|
|
48
|
+
if notes:
|
|
49
|
+
with open(out_path, encoding="utf-8") as f:
|
|
50
|
+
body = f.read()
|
|
51
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
52
|
+
f.write("\n".join(notes) + "\n" + body)
|
|
53
|
+
return out_path, key
|
|
54
|
+
|