OPEN SOURCE
software/src/output/alignment.py
Read on this site and download the files you need.
Nichijoyaku was created using AI. A complete review by human biblical-language and biblical-studies specialists has not been established. Read alongside established translations and original-language resources.
← Back to the open-source guide
1"""Apply reviewed reference routes to a release candidate, retaining every conflict.23This never edits the translation harness's source-numbered output. An audit is4bound to a complete input snapshot; no lexical similarity score authorizes edits.5"""6from __future__ import annotations78import hashlib9import json10import re11from collections import Counter, defaultdict12from pathlib import Path1314from src.output.originals import load_site151617def digest(value):18return hashlib.sha256(value.encode()).hexdigest()192021def read_output(directory):22records, files = {}, {}23for path in sorted(Path(directory).glob('*/chapter_*.json')):24content = path.read_bytes()25files[str(path.relative_to(directory))] = hashlib.sha256(content).hexdigest()26data = json.loads(content)27for verse, record in data['verses'].items():28key = f'{path.parent.name}:{data["chapter"]}:{int(verse)}'29if key in records or not record['text'].strip():30raise ValueError(f'Duplicate or empty translation: {key}')31records[key] = record['text']32if not records:33raise ValueError('No translations found')34return records, files353637def ref_order(ref):38book, ch, verse = ref.split(':')39return book, int(ch), int(verse)404142def candidate_hashes(choices):43return sorted(({'source': item['source'], 'text_sha256': digest(item['text'])}44for item in choices), key=lambda item: ref_order(item['source']))454647def validate_repair(target, repair, choices):48if repair['expected_candidates'] != candidate_hashes(choices):49raise ValueError(f'Repair candidates changed: {target}')50text = repair['text']51if not isinstance(text, str) or not text.strip() or re.search(r'[\x00-\x1f\x7f<>\ufffd]|```', text):52raise ValueError(f'Invalid repaired text: {target}')53if not repair.get('reason') or not repair.get('evidence_sha256') or not repair.get('verified_by'):54raise ValueError(f'Repair lacks review evidence: {target}')55return text565758def route_record(source, text, review):59"""Return lossless character slices, or hold a record requiring more review."""60if review is None:61return [(source, text)]62if review['text_sha256'] != digest(text):63raise ValueError(f'Reviewed text changed: {source}')64routes = review.get('routes')65if routes is None:66decision = review['decision']67if decision == 'same':68return [(source, text)]69if decision == 'mapped' and len(review['mapped_targets']) == 1:70return [(review['mapped_targets'][0], text)]71if decision not in ('mapped', 'mixed', 'unclear'):72raise ValueError(f'Unknown review decision: {source}')73return []74cursor, result = 0, []75for route in routes:76start, end = route['start'], route['end']77if start != cursor or not start < end <= len(text):78raise ValueError(f'Character loss or duplication: {source}')79result.append((route['target'], text[start:end]))80cursor = end81if cursor != len(text):82raise ValueError(f'Unassigned characters: {source}')83return result848586def align(records, site_refs, review):87candidates, held = defaultdict(list), {}88reviewed = review['records']89if reviewed.keys() - records.keys():90raise ValueError('Review contains unknown source references')91for source, text in records.items():92routes = route_record(source, text, reviewed.get(source))93if not routes:94held[source] = {'text': text, 'review': reviewed[source]}95for target, part in routes:96book, ch, verse = ref_order(target)97if target not in site_refs and not (verse == 0 and f'{book}:{ch}:1' in site_refs):98raise ValueError(f'Route outside site: {source} -> {target}')99candidates[target].append({'source': source, 'text': part})100resolved, conflicts, dispositions = {}, {}, {}101repairs = review.get('target_repairs', {})102for target in repairs:103if target not in site_refs:104raise ValueError(f'Repair outside site: {target}')105candidates.setdefault(target, [])106resolutions = review.get('resolutions', {})107if resolutions.keys() - candidates.keys():108raise ValueError('Resolution has no candidates')109for target, choices in sorted(candidates.items(), key=lambda item: ref_order(item[0])):110choices.sort(key=lambda item: ref_order(item['source']))111if target in repairs:112repair = repairs[target]113text = validate_repair(target, repair, choices)114dispositions[target] = {'reason': repair['reason'], 'all_candidates': choices,115'repair': repair}116resolved[target] = {'text': text, 'sources': [item['source'] for item in choices],117'restored': not choices, 'reviewed_repair': True}118continue119resolution = resolutions.get(target)120if resolution:121actual = [item['source'] for item in choices]122if sorted(actual) != sorted(resolution['expected_candidates']):123raise ValueError(f'Resolution candidates changed: {target}')124by_source = {item['source']: item for item in choices}125selected = resolution['sources']126if len(set(actual)) != len(actual) or not selected or len(set(selected)) != len(selected) or set(selected) - by_source.keys():127raise ValueError(f'Invalid selection: {target}')128parts = [by_source[source] for source in selected]129dispositions[target] = {'reason': resolution['reason'], 'selected': selected, 'all_candidates': choices}130elif target.endswith(':0'):131parts = choices # Consecutive, separately numbered superscription lines.132elif len({item['text'] for item in choices}) == 1:133parts = choices[:1]134if len(choices) > 1:135dispositions[target] = {'reason': 'Identical text at the same destination', 'selected': [parts[0]['source']], 'all_candidates': choices}136else:137conflicts[target] = choices138continue139resolved[target] = {'text': ''.join(item['text'] for item in parts), 'sources': [item['source'] for item in parts]}140missing = sorted(site_refs - candidates.keys(), key=ref_order)141extras = {}142for source in sorted(records.keys() - site_refs, key=ref_order):143targets = [t for t, choices in candidates.items() if any(item['source'] == source for item in choices)]144extras[source] = {'text': records[source], 'targets': targets,145'basis': reviewed[source]['basis'], 'decision': reviewed[source]['decision'],146'status': 'held' if source in held else ('conflict' if any(t in conflicts for t in targets) else 'resolved'),147'destinations': {t: {'candidates': candidates[t], 'selected': resolved.get(t)} for t in targets}}148changes = {key: value for key, value in resolved.items() if value['sources'] != [key] or value['text'] != records.get(key)}149return {'schema': 1, 'translation_name': '日常訳第1版',150'numbering_ready': not (missing or conflicts or held),151'summary': {'source_records': len(records), 'site_references': len(site_refs),152'reviewed_sensitive_records': len(reviewed), 'reviewers': dict(Counter(r['reviewer'] for r in reviewed.values())),153'resolved_site_references': len(site_refs & resolved.keys()), 'aligned_changes': len(changes),154'missing_passages': len(missing), 'conflicting_destinations': len(conflicts),155'held_records': len(held), 'extra_references': len(extras)},156'aligned_changes': changes, 'missing_passages': missing, 'conflicts': conflicts,157'held_records': held, 'resolutions': dispositions, 'extra_references': extras}158159160def build_audit(source, review_path, out):161source, review_path, out = Path(source), Path(review_path), Path(out).resolve()162if out.is_relative_to(source.resolve()) or out == review_path.resolve():163raise ValueError('Audit must not overwrite its inputs')164records, files = read_output(source)165review = json.loads(review_path.read_text())166if files != review['input_files']:167raise ValueError('Translation snapshot changed; refresh the affected reviews')168site, _ = load_site()169site_refs = {':'.join(map(str, key)) for key in site}170# Bind the reference text too, since numbering alone cannot prove alignment.171site_hash = digest(json.dumps({':'.join(map(str, k)): v for k, v in site.items()}, ensure_ascii=False, sort_keys=True))172if review['site_sha256'] != site_hash:173raise ValueError('Site reference text changed; refresh the review')174for relative, expected in review.get('repair_source_files', {}).items():175path = (source.parent / relative).resolve()176if not path.is_relative_to((source.parent / 'sources').resolve()):177raise ValueError('Repair source path outside sources directory')178if hashlib.sha256(path.read_bytes()).hexdigest() != expected:179raise ValueError(f'Repair source text changed: {relative}')180result = align(records, site_refs, review)181result['missing_reference_text'] = {ref: site[ref_order(ref)] for ref in result['missing_passages']}182result['input_files'] = files183result['site_sha256'] = site_hash184result['review_sha256'] = hashlib.sha256(review_path.read_bytes()).hexdigest()185result['usage'] = review['usage']186out.parent.mkdir(parents=True, exist_ok=True)187out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n')188return result189190191def export_aligned(source, audit_path, out):192"""Write a complete, separately numbered release; never mutate raw records."""193source, audit_path, out = Path(source), Path(audit_path), Path(out).resolve()194if out.is_relative_to(source.resolve()) or source.resolve().is_relative_to(out):195raise ValueError('Release must be separate from raw translation output')196if out.exists() and any(out.iterdir()):197raise ValueError('Release destination must be empty')198records, files = read_output(source)199audit = json.loads(audit_path.read_text())200if not audit['numbering_ready'] or files != audit['input_files'] or any(201audit.get(key) for key in ('missing_passages', 'conflicts', 'held_records')202):203raise ValueError('Numbering audit incomplete or source snapshot changed')204site, codes = load_site()205site_hash = digest(json.dumps({':'.join(map(str, k)): v for k, v in site.items()}, ensure_ascii=False, sort_keys=True))206if audit['site_sha256'] != site_hash:207raise ValueError('Release reference text changed')208books = {book: {'schema': 1, 'book': code, 'translation': '日常訳第1版', 'chapters': {}} for book, code in codes.items()}209changes = audit['aligned_changes']210for key in sorted(site):211book, ch, verse = key212ref = ':'.join(map(str, key))213text = changes[ref]['text'] if ref in changes else records.get(ref)214if not text:215raise ValueError(f'Missing release verse: {ref}')216chapter = books[book]['chapters'].setdefault(str(ch), {'heading': '', 'verses': {}})217chapter['verses'][str(verse)] = text218for ref, change in changes.items():219book, ch, verse = ref_order(ref)220if verse == 0:221books[book]['chapters'][str(ch)]['heading'] = change['text']222out.mkdir(parents=True, exist_ok=True)223hashes = {}224for book, payload in books.items():225filename = codes[book] + '.json'226text = json.dumps(payload, ensure_ascii=False, separators=(',', ':')) + '\n'227(out / filename).write_text(text)228hashes[filename] = digest(text)229manifest = {'schema': 1, 'translation': '日常訳第1版', 'verses': len(site),230'chapters': len({k[:2] for k in site}), 'books': len(books), 'files': hashes,231'audit_sha256': hashlib.sha256(audit_path.read_bytes()).hexdigest()}232(out / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')233return manifest234