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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
+