"""Facts derived straight from PRIMARY sources (D50). Used by both the site build (build_tags.py)
and the verifier, so the site never carries a chain/file fact that cannot be recomputed.
Sources: bitgan_properties.csv (Pindar's Arweave sheet), chain_raw_by_mintindex.json (raw chain traits),
lore_catalog_runs/gif_facts.json (measured from the GIF bytes), token_index.json (piece -> mint),
burnt_final.json (burn wallet transfers), bip39.txt."""
import json, csv, re, collections, math, os
W = '/Users/michaelconnery/Desktop/bitgan_puzzle_work/'
bip = open(W + 'bip39.txt').read().split(); BIP = set(bip)
DROP = lambda g: 'skull' if g <= 128 else 'robo' if g <= 192 else 'spooky' if g <= 208 else 'solstice' if g <= 300 else 'ghost' if g <= 444 else 'review'
STD_FRAMES = {'skull':41,'robo':55,'spooky':55,'solstice':55,'ghost':55,'review':55}

# ---------- Pindar's sheet, mapped to piece numbers ----------
_ar = list(csv.DictReader(open(W + 'bitgan_properties.csv', encoding='utf-8-sig')))
SHEET = {}; _rev = 0
for r in _ar:
    n, dr = r['Number'].strip(), r['Drop']
    if dr == 'skullGANs': g = int(n)
    elif dr == '2021.9.29.1202': g = 129 + int(n, 2)
    elif dr == '2021.10.17': g = 193 + int(n[2:], 8)
    elif dr in ('2021.12.21.1202', 'Ghosts in the Machine'): g = int(n)
    elif dr == 'Act 1 - Review': g = 445 + _rev; _rev += 1      # listed in order; 14 rows carry binary/hex counters
    else: g = None
    if g and 1 <= g <= 512: SHEET[g] = r
assert len(SHEET) == 512
ODD_NUMBERING = {g for g, r in SHEET.items() if DROP(g) == 'review' and (not r['Number'].strip().isdigit() or int(r['Number']) != g)}

# ---------- chain ----------
_C = json.load(open(W + 'chain_raw_by_mintindex.json'))
_TI = {int(k): int(v) for k, v in json.load(open(W + 'token_index.json')).items()}   # piece -> mint slot
CHAIN = {}
for g, m in _TI.items():
    v = _C.get(str(m))
    if v and v.get('traits'): CHAIN[g] = v                      # by mint slot (D53): the 14 binary/hex-numbered Review pieces were mislabelled 'gap'
for v in _C.values():
    if v.get('label', '').startswith('gan:'): CHAIN.setdefault(int(v['label'][4:]), v)
NO_CHAIN = sorted(set(range(1, 513)) - set(CHAIN))
_names = collections.defaultdict(list)
for k, v in _C.items():
    if v.get('name'): _names[v['name']].append(int(k))
DOUBLE_MINT = {}
for nm, slots in _names.items():
    if len(slots) > 1:
        for s in slots:
            lab = _C[str(s)].get('label', '')
            if lab.startswith('gan:'): DOUBLE_MINT[int(lab[4:])] = sorted(slots)
def ctraits(g):
    t = dict(CHAIN.get(g, {}).get('traits', {}))
    if 'Subtype' in t and 'SubType' not in t: t['SubType'] = t['Subtype']
    return t
TRAIT_KEY_ANOMALY = {g: [k for k in v['traits'] if k not in ('Drop','Eyes','GANg','Type','SubType','Essence','Dimensions','HiddenType','Name','Date','Level')] for g, v in CHAIN.items()}
TRAIT_KEY_ANOMALY = {g: ks for g, ks in TRAIT_KEY_ANOMALY.items() if ks}
# fields a whole drop lacks on chain are conventions, not differences
_drop_has = collections.defaultdict(collections.Counter)
for g, v in CHAIN.items():
    for k in ctraits(g): _drop_has[DROP(g)][k] += 1
def chain_has_field(drop, f): return _drop_has[drop][f] > 0
NO_ESSENCE_ON_CHAIN = {g for g in CHAIN if not chain_has_field(DROP(g), 'Essence')}
NAMED_ON_CHAIN_ONLY = {g for g in CHAIN if ctraits(g).get('Name') and not SHEET[g]['Name'].strip()}

# ---------- sheet vs chain, field by field ----------
def norm(s): return re.sub(r'\s+', ' ', (s or '')).strip()
def strip_num(t): return re.sub(r'\s*\((\d+/\d+|#\d+|[01]{6,7}|0o\d+|0x[0-9a-fA-F]+|[01]{7}/512)\)\s*$', '', norm(t))
DIFFS = {}      # g -> [(field, sheet, chain)]
for g, v in CHAIN.items():
    r = SHEET[g]; tr = ctraits(g); dl = []
    ct, st = strip_num(v['name']), strip_num(r['Title'])
    if ct != st: dl.append(('Title', st, ct))
    for f in ('GANg','Type','SubType','Essence','Eyes','Dimensions','Name'):
        if not chain_has_field(DROP(g), f): continue
        sv, cv = norm(r.get(f, '')), norm(tr.get(f, ''))
        if f == 'Name' and not sv: continue          # chain-only names are their own fact
        if sv != cv: dl.append((f, sv, cv))
    if dl: DIFFS[g] = dl
# value frequencies, to say which side is the odd one out
_freq = collections.Counter()
for g in SHEET:
    for f in ('Title','GANg','Type','SubType'):
        _freq[(f, strip_num(SHEET[g].get(f, '')).lower())] += 1
    for f, v in ctraits(g).items():
        if f in ('GANg','Type','SubType'): _freq[(f, norm(v).lower())] += 1
    if g in CHAIN: _freq[('Title', strip_num(CHAIN[g]['name']).lower())] += 1
_tokfreq = collections.Counter()
for g in SHEET:
    for t in strip_num(SHEET[g]['Title']).split(): _tokfreq[t] += 1
    if g in CHAIN:
        for t in strip_num(CHAIN[g]['name']).split(): _tokfreq[t] += 1
def odd_side(f, sv, cv):
    """which side holds the value the rest of the collection does not use"""
    if f == 'Title':
        a, b = sv.split(), cv.split()
        rare_s = [t for t in a if _tokfreq[t] <= 2 and t not in b]; rare_c = [t for t in b if _tokfreq[t] <= 2 and t not in a]
        if rare_c and not rare_s: return 'chain'
        if rare_s and not rare_c: return 'sheet'
        if len(a) == len(b):
            for x, y in zip(a, b):
                if x != y and (x.lower().startswith(y.lower()) or y.lower().startswith(x.lower())): return 'chain (truncated)' if len(y) < len(x) else 'sheet (truncated)'
        if len(b) < len(a): return 'chain (part missing)'
        if len(a) < len(b): return 'sheet (part missing)'
        return 'both are real values'
    if f not in ('GANg','Type','SubType'): return 'unknown'
    ns, nc = _freq[(f, sv.lower())] - 1, _freq[(f, cv.lower())] - 1
    if not sv: return 'sheet (value missing)'
    if not cv: return 'chain (value missing)'
    if ns >= 3 and nc < 3: return 'chain'
    if nc >= 3 and ns < 3: return 'sheet'
    return 'both are real values'
def kind_of(f, sv, cv):
    s, c = sv.lower(), cv.lower()
    if f in ('Eyes','Dimensions'): return 'number'
    if s == c: return 'case'
    if s and c and sorted(s) == sorted(c) and len(s) == len(c):
        return 'reverse' if s[::-1] == c else 'transpose'
    if s and c and s.replace(' ', '') == c.replace(' ', ''): return 'delete'      # a space dropped
    if not s or not c: return 'delete'
    if _subseq(c, s) or _subseq(s, c): return 'delete'      # one side is the other with characters removed
    return 'substitute'
def _subseq(short, long):
    it = iter(long); return all(ch in it for ch in short)
CLASSES = {}    # g -> set of kinds; swap detected on Type<->SubType
for g, dl in DIFFS.items():
    fields = {f: (sv, cv) for f, sv, cv in dl}
    if 'Type' in fields and 'SubType' in fields and fields['Type'][0].lower() == fields['SubType'][1].lower() and fields['SubType'][0].lower() == fields['Type'][1].lower():
        CLASSES[g] = {'swap'}; continue
    CLASSES[g] = {kind_of(f, sv, cv) for f, sv, cv in dl}
TWINS_RENAME = {g for g, dl in DIFFS.items() if any(f == 'SubType' and sv.lower() == 'twin' and cv.lower() == 'twins' for f, sv, cv in dl)}
def diff_lines(g):
    out = []
    for f, sv, cv in DIFFS.get(g, []):
        o = odd_side(f, sv, cv)
        out.append(f"{f}: {sv or '(blank)'} (sheet) · {cv or '(blank)'} (chain) — " + ('both values are common elsewhere; which is wrong is a judgement' if o == 'both are real values' else 'no basis to say which side is wrong' if o == 'unknown' else f'rarer value on the {o} side'))
    return ' | '.join(out)

# ---------- chain names ----------
CNAME = {g: v['name'] for g, v in CHAIN.items()}
NAME_DOUBLE_SPACE = {g for g, n in CNAME.items() if '  ' in n}
NAME_CASE = {g for g, n in CNAME.items() if re.search(r'Gan\b', n)} | {g for g, dl in DIFFS.items() if any(f == 'Title' and sv.lower() == cv.lower() and sv != cv for f, sv, cv in dl)}
NAME_STRAY = {g: n for g, n in CNAME.items() if re.search(r'[`~^|]', n)}
NAME_RADIX = {g for g, r in SHEET.items() if re.search(r'\(0x[0-9a-fA-F]+\)', r['Title'])}
NAME_LEET = {g for g, r in SHEET.items() if re.search(r'[A-Za-z][0-9]+[A-Za-z]|(?<![\w/#])[0-9]+[A-Za-z]{2,}', strip_num(r['Title']))}
def title_species(t):
    m = re.search(r'([A-Za-z]+GAN)s?\b', strip_num(t)); return m.group(1) if m else None
NAME_SPECIES_MISMATCH = {g for g, r in SHEET.items() if title_species(r['Title']) and title_species(r['Title']).lower() != r['GANg'].strip().lower()}

# typos: a word on one side within edit distance 2 of the other side's word (same word count)
def _ed(a, b):
    """optimal-string-alignment distance: insert, delete, substitute, or swap two adjacent letters = 1"""
    if abs(len(a) - len(b)) > 2: return 3
    d = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(len(a) + 1): d[i][0] = i
    for j in range(len(b) + 1): d[0][j] = j
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            cost = 0 if a[i-1] == b[j-1] else 1
            d[i][j] = min(d[i-1][j] + 1, d[i][j-1] + 1, d[i-1][j-1] + cost)
            if i > 1 and j > 1 and a[i-1] == b[j-2] and a[i-2] == b[j-1]: d[i][j] = min(d[i][j], d[i-2][j-2] + 1)
    return d[-1][-1]
def _word_typo(sv, cv):
    a, b = sv.lower().split(), cv.lower().split()
    if len(a) != len(b): return False
    return any(x != y and _ed(x, y) == 1 for x, y in zip(a, b))
NAME_TYPO = {g for g, dl in DIFFS.items() if any(f == 'Title' and _word_typo(sv, cv) for f, sv, cv in dl)}
# a trait value that is a misspelling of a value used elsewhere (on either side, or on both)
_vocab = collections.Counter()
for g in SHEET:
    for f in ('GANg','Type','SubType'):
        _vocab[norm(SHEET[g].get(f, '')).lower()] += 1; _vocab[norm(ctraits(g).get(f, '')).lower()] += 1
TRAIT_VALUE_TYPO = {}
for g in SHEET:
    for f in ('GANg','Type','SubType'):
        for side, val in (('sheet', norm(SHEET[g].get(f, ''))), ('chain', norm(ctraits(g).get(f, '')))):
            v = val.lower()
            if v and _vocab[v] <= 4:
                near = [w for w, n in _vocab.items() if n >= 5 and _ed(v, w) == 1]
                if near: TRAIT_VALUE_TYPO.setdefault(g, []).append(f"{f} '{val}' ({side}) for '{near[0]}'")
_deck = {r['GAN']: r for r in json.load(open(W + 'deck_512.json')) if isinstance(r.get('GAN'), int)}
FILENAME = {g: (_deck.get(g, {}).get('ArweaveFilename') or '') for g in range(1, 513)}
_ftok = collections.Counter(t.lower() for fn in FILENAME.values() for t in re.split(r'[_\-.]', fn) if t and not t.isdigit())
FILENAME_TYPO = {}
for g, fn in FILENAME.items():
    for t in re.split(r'[_\-.]', fn):
        t = t.lower()
        if t and not t.isdigit() and not re.search(r'\d', t) and _ftok[t] <= 2:
            near = [w for w, n in _ftok.items() if n >= 5 and not re.search(r'\d', w) and _ed(t, w) == 1]
            if near: FILENAME_TYPO[g] = f"'{t}' for '{near[0]}' in {fn}"
ESSENCE_NOT_PLAIN = {g: SHEET[g]['Essence'].strip() for g in SHEET if SHEET[g]['Essence'].strip() and SHEET[g]['Essence'].strip() not in BIP}
_FILENAME_TYPO_FN = None

# ---------- burns ----------
_BF = json.load(open(W + 'burnt_final.json'))
BURNT = {b['GAN']: b for b in _BF if isinstance(b.get('GAN'), int) and 1 <= b['GAN'] <= 512}
_bytx = collections.defaultdict(set); _byblk = collections.defaultdict(set)
for g, b in BURNT.items(): _bytx[b['tx']].add(g); _byblk[b['blk']].add(g)
def burnt_with(g):
    b = BURNT.get(g)
    if not b: return None
    tx = _bytx[b['tx']] - {g}; blk = _byblk[b['blk']] - {g}
    if tx: return ('same transaction', sorted(tx))
    if blk: return ('same block', sorted(blk))
    return None

# ---------- descriptions ----------
# D66 (audit 2026-09-16): the old rule asked "is this description unique inside its drop?",
# which missed every defect shared by two pieces (#301/#302, #303/#304, #305/#306), and it
# compared only a 120-character head, which cannot see a defect in the closing line
# ("for this Image") or injected tabs. It now compares the WHOLE description against the
# drop's majority text, with the per-piece Arweave links normalised out. 7 members -> 15.
try:
    _FULLDESC = {int(k): v for k, v in json.load(open(W + 'chain_descriptions.json'))['desc'].items()}
except Exception:
    _FULLDESC = {}
def _desc_norm(g):
    d = _FULLDESC.get(g)
    if d is None: return CHAIN.get(g, {}).get('description_head', '')
    return re.sub(r'https://arweave\.net/\S+', '<AR>', d)
_dh = collections.defaultdict(collections.Counter)
for g in CHAIN: _dh[DROP(g)][_desc_norm(g)] += 1
_boiler = {d: c.most_common(1)[0][0] for d, c in _dh.items()}
DESC_ANOMALY = {g: _desc_norm(g) for g in CHAIN
                if len(_dh[DROP(g)]) > 1 and _desc_norm(g) != _boiler[DROP(g)]}

# ---------- files ----------
GF = {int(k): v for k, v in json.load(open(W + 'lore_catalog_runs/gif_facts.json')).items()}
# the normal file: every frame carries the drop's standard delay (skull 0 ms, every later drop 100 ms)
_pat = {}
for d in STD_FRAMES:
    c = collections.Counter(tuple(sorted(set(GF[g]['delays']))) for g in GF if DROP(g) == d and GF[g].get('delays'))
    _pat[d] = max(c, key=c.get)          # e.g. (100,) or (0,)
WRONG_SPEED = {}; HELD_FRAME = {}
for g, f in GF.items():
    dl = f.get('delays') or []
    if not dl or tuple(sorted(set(dl))) == _pat[DROP(g)]: continue
    nz = [(i, x) for i, x in enumerate(dl) if x]
    if len(nz) == 1 and len(dl) > 2: HELD_FRAME[g] = nz[0]                     # one held frame in an otherwise 0 ms file
    else: WRONG_SPEED[g] = collections.Counter(dl).most_common(2)
FRAME_COUNT_ODD = {g: f['frames'] for g, f in GF.items() if f.get('frames') and f['frames'] != STD_FRAMES[DROP(g)]}
_md5 = collections.defaultdict(set)
for g, f in GF.items(): _md5[f['md5']].add(g)
BYTE_IDENTICAL = {g: sorted(gs - {g}) for gs in _md5.values() if len(gs) > 1 for g in gs}

# ---------- numbers ----------
TI = {int(k): int(v) for k, v in json.load(open(W + 'token_index.json')).items()}   # piece -> mint
def is_prime(n): return n > 1 and all(n % k for k in range(2, int(math.isqrt(n)) + 1))
FIB = {0,1,2,3,5,8,13,21,34,55,89,144,233,377,610}
def numclass(n):
    if n in FIB: return 'Fibonacci'
    if is_prime(n): return 'prime (not Fibonacci)'
    if math.isqrt(n) ** 2 == n: return 'square'
    return 'odd (remaining)' if n % 2 else 'even (remaining)'
def native_pos(g):
    d = DROP(g); return g if d == 'skull' else g - 129 if d == 'robo' else g - 193 if d == 'spooky' else None
def native_base(g):
    return {'skull':'decimal /128','robo':'binary 6-bit','spooky':'octal 0o','solstice':'absolute #','ghost':'decimal /144','review':'absolute /512'}[DROP(g)]
def native_class(g):
    p = native_pos(g)
    if p is None: return None
    if DROP(g) == 'robo':
        b = format(p, '06b')
        if b == b[::-1]: return 'palindrome'
    return numclass(p)
LETTER = {'a':'Fibonacci','b':'prime (not Fibonacci)','c':'square','d':'odd (remaining)','e':'even (remaining)','f':'palindrome'}
HIDDENTYPE = {g: ctraits(g).get('HiddenType') for g in CHAIN if ctraits(g).get('HiddenType') is not None}
def ht_meaning(g):
    ht = HIDDENTYPE.get(g)
    if ht is None: return None
    if ht == '32': return 'literal 32 (misfile)'
    if ht.lower() == 'f' and DROP(g) != 'robo': return 'F outside the robo drop (misfile)'
    return LETTER.get(ht.lower(), ht)
HT_MISFILE = {}
for g, ht in HIDDENTYPE.items():
    exp = native_class(g)
    if ht == '32': HT_MISFILE[g] = f"trait reads '32' instead of a letter; position {native_pos(g)} is {exp}"
    elif ht.lower() == 'f' and exp != 'palindrome': HT_MISFILE[g] = f"letter F outside the robo drop: the palindrome class exists only for robos (collector's ruling, D52); {DROP(g)} position {native_pos(g)} is {exp}, so the scheme would give '{[k for k, v in LETTER.items() if v == exp][0]}'"
    elif ht.lower() != 'f' and LETTER.get(ht.lower()) != exp: HT_MISFILE[g] = f"letter '{ht}' = {LETTER.get(ht.lower())}, but position {native_pos(g)} is {exp}"
HT_PALINDROME = {g for g, ht in HIDDENTYPE.items() if ht.lower() == 'f' and DROP(g) == 'robo'}
PRIME_NOT_FIB_ROBO = {g for g in range(129, 193) if is_prime(g - 129) and (g - 129) not in FIB}

# ---------- save sessions (from the Photoshop XMP identifiers; UUID v1 timestamps) ----------
import datetime as _dt
XMP = {int(k): v for k, v in json.load(open(W + 'lore_catalog_runs/xmp_ids.json')).items()}
SAVE_TIME = {g: _dt.datetime.fromisoformat(v['t_iid']) for g, v in XMP.items() if v.get('t_iid')}
TOOL = {g: v['tool'] for g, v in XMP.items()}
_order = sorted(SAVE_TIME.items(), key=lambda kv: kv[1])
SESSIONS = []; _cur = [_order[0]]
for g, t in _order[1:]:
    if (t - _cur[-1][1]).total_seconds() > 3 * 3600: SESSIONS.append(_cur); _cur = []
    _cur.append((g, t))
SESSIONS.append(_cur)
SESSION_OF = {g: i + 1 for i, s in enumerate(SESSIONS) for g, _ in s}
SESSION_SIZE = {i + 1: len(s) for i, s in enumerate(SESSIONS)}
POINT_RELEASE = {g for g, t in TOOL.items() if re.search(r'\d+\.\d+ \(Windows\)', t or '')}     # 22.3 / 22.5 / 23.0 / 23.1 builds
SOLO_SESSION = {g for g, sid in SESSION_OF.items() if SESSION_SIZE[sid] <= 2}
def session_note(g):
    sid = SESSION_OF[g]; s = SESSIONS[sid - 1]
    return f"saved {SAVE_TIME[g]:%Y-%m-%d %H:%M} with {TOOL[g]}; session {sid} of 39 ({len(s)} file{'s' if len(s) != 1 else ''}, {s[0][1]:%Y-%m-%d %H:%M}–{s[-1][1]:%H:%M})"

# ---------- lore facts that data can settle ----------
_QV = re.compile(r'quantum|qubit|entangle|superposition|\bspin\b', re.I)
QUANTUM_VOCAB = {g: [f for f in ('Title','Name','GANg','Type','SubType','Essence') if _QV.search(SHEET[g].get(f, ''))] for g in SHEET}
QUANTUM_VOCAB = {g: fs for g, fs in QUANTUM_VOCAB.items() if fs}
NAMED_WITH_SEED_WORD = {}
for g, r in SHEET.items():
    if not r['Name'].strip(): continue
    ws = [w for w in re.findall(r'[A-Za-z]+', strip_num(r['Title']) + ' ' + r['Name']) if w.lower() in BIP]
    if ws: NAMED_WITH_SEED_WORD[g] = sorted(set(w.lower() for w in ws))

# ---------- filename words (from the archive filenames; ArDrive listing confirms 64 of them verbatim) ----------
# D55: every piece's file was downloaded from Pindar's ArDrive archives and hashed; all 512 are byte-identical to our
# copies, and the archive filenames replace the collector-folder names as the source (lore_catalog_runs/arweave_filenames.json)
_ARNAME = {int(k): v for k, v in json.load(open(W + 'lore_catalog_runs/arweave_filenames.json')).items()}
FILENAME = {g: (_ARNAME.get(g) or [FILENAME.get(g, '')])[0] for g in range(1, 513)}
FILENAME_CONFIRMED = {g for g in _ARNAME}
ARWEAVE_FOLDER = {g: ('01_skullgans' if g <= 128 else '02_robotgans' if g <= 192 else '03_spookygans1' if g <= 208 else '04_solgans' if g <= 300 else '05_ghostgans' if g <= 444 else '06_reprisedgans') for g in range(1, 513)}
_TRAITVOCAB = {norm(SHEET[g][f]).lower() for g in SHEET for f in ('GANg','Type','SubType')} | {norm(v).lower() for g in CHAIN for f, v in ctraits(g).items() if f in ('GANg','Type','SubType')}
_TRAITVOCAB |= {w[:-3] for w in _TRAITVOCAB if w.endswith('gan')}
_ftok = collections.Counter(t.lower() for fn in FILENAME.values() for t in re.split(r'[_\-.]', fn) if t and not t.isdigit())
FILENAME_TYPO = {}
for g, fn in FILENAME.items():
    for t in re.split(r'[_\-.]', fn):
        t = t.lower()
        if t and not t.isdigit() and not re.search(r'\d', t) and _ftok[t] <= 2:
            near = [w for w, n in _ftok.items() if n >= 5 and not re.search(r'\d', w) and _ed(t, w) == 1]
            if near: FILENAME_TYPO[g] = f"'{t}' for '{near[0]}' in {fn}"
FILENAME_WORDS = {}
for g, fn in FILENAME.items():
    if not fn: continue
    meta = ' '.join(str(SHEET[g].get(k, '')) for k in ('Title','Name','GANg','Type','SubType','Essence')).lower()
    out = []
    for t in re.split(r'[_\-.\d]+', fn):
        t = t.lower()
        if not t or t in ('gif','s') or t in _TRAITVOCAB or t in meta or t not in BIP or t in out: continue
        out.append(t)
    if out: FILENAME_WORDS[g] = out

# ---------- negative (colour-inverted) frames, from the calibrated scan of every GIF (D54) ----------
# a frame is negative when its colour inverse sits far closer to the previous frame than the frame itself does
NEGATIVE_FRAMES = {int(k): v for k, v in json.load(open(W + 'lore_catalog_runs/negative_scan.json')).items()}

# ---------- first transfer out of Pindar's wallet (Blockscout token transfer history, D56) ----------
_TR = {int(k): v for k, v in json.load(open(W + 'lore_catalog_runs/transfers.json')).items()}
_CREATOR = '0x55372173689C288552885D897d32f5F706F79aA6'.lower()
FIRST_TRANSFER = {}
for g, v in _TR.items():
    outs = sorted((t['ts'] for t in v.get('transfers', []) if (t.get('from') or '').lower() == _CREATOR and t.get('ts')))
    if outs: FIRST_TRANSFER[g] = outs[0][:16].replace('T', ' ')
def transfer_period(g):
    t = FIRST_TRANSFER.get(g)
    if not t: return None
    if t < '2022-01-01': return 'at the drop, in 2021'
    if t < '2022-01-31 19:42': return 'January 2022, before the sheet'
    if t < '2022-02-05': return '1–4 Feb 2022, between the sheet and the clue'
    if t < '2022-02-06': return 'on the clue day, 5 Feb 2022'
    if t < '2022-08-24': return 'Feb–Aug 2022, after the clue'
    return 'late Aug 2022, around the "looking all wrong" tweet'

# ---------- D57: which side is wrong, by three witnesses (usage frequency, the Arweave filename, Pindar's journal) ----------
def _toks(s): return {t for t in re.findall(r'[a-z]+', (s or '').lower())}
JOURNAL_WITNESS = {39: 'chain', 44: 'sheet'}    # journal 003 calls #39 'the cyclopsGAN'; journal 003 lists #44 among the Named as 'Ancient'? (no: 44 is not Named) -> 44 by filename only
JOURNAL_WITNESS = {39: 'chain'}
def witness(g):
    """returns (verdict, reason). verdict in: 'chain wrong', 'sheet wrong', 'both sides odd', 'undecidable', 'convention'"""
    if g in TWINS_RENAME and all(f == 'SubType' for f, _, _ in DIFFS.get(g, [])): return ('convention', "Twin/Twins rename only")
    fn = FILENAME.get(g, ''); ft = _toks(re.sub(r'^\d+_?', '', fn.rsplit('.', 1)[0])) if DROP(g) not in ('robo', 'spooky') else set()
    verdicts = []; reasons = []
    for f, sv, cv in DIFFS.get(g, []):
        if f == 'SubType' and sv.lower() == 'twin' and cv.lower() == 'twins': continue
        st, ct = _toks(sv), _toks(cv); s_hit = any(t in ft for t in st - ct); c_hit = any(t in ft for t in ct - st)
        if g in JOURNAL_WITNESS: verdicts.append(JOURNAL_WITNESS[g] + ' right'); reasons.append("Pindar's journal names the chain's value"); continue
        if s_hit and not c_hit: verdicts.append('sheet right'); reasons.append(f"filename '{fn}' carries the sheet's word"); continue
        if c_hit and not s_hit: verdicts.append('chain right'); reasons.append(f"filename '{fn}' carries the chain's word"); continue
        o = odd_side(f, sv, cv)
        if o.startswith('chain'): verdicts.append('sheet right'); reasons.append(f"{f}: the chain's value is used nowhere else" if 'missing' not in o and 'truncated' not in o and 'part' not in o else f"{f}: the chain drops part of the value")
        elif o.startswith('sheet'): verdicts.append('chain right'); reasons.append(f"{f}: the sheet's value is used nowhere else")
        else: verdicts.append('open'); reasons.append(f"{f}: both values are real and no filename or journal witness")
    vs = set(verdicts)
    if vs == {'sheet right'}: return ('chain wrong', '; '.join(reasons))
    if vs == {'chain right'}: return ('sheet wrong', '; '.join(reasons))
    if 'open' in vs and len(vs) == 1: return ('undecidable', '; '.join(reasons))
    return ('mixed', '; '.join(reasons))
WITNESS = {g: witness(g) for g in DIFFS}
CHAIN_WRONG = sorted(g for g, (v, _) in WITNESS.items() if v == 'chain wrong')
SHEET_WRONG = sorted(g for g, (v, _) in WITNESS.items() if v == 'sheet wrong')
UNDECIDED = sorted(g for g, (v, _) in WITNESS.items() if v in ('undecidable', 'mixed'))

# ---------- D59: the collector's ruling on substitutions ----------
# Michael (14 Sep 2026): every sheet-vs-chain SUBSTITUTION echoes a visual element of the piece that the data cannot see —
# the substituted value describes what the art shows, the other side keeps the formula label. So substitutions are not
# errors on either side: they are the label bending toward the picture.
def _is_word_substitution(g):
    """two different real words for one trait (not a displacement like #58, not the #62 swap, not Twin/Twins, not the backtick)"""
    if CLASSES.get(g) == {'swap'} or g == 58: return False
    return any(kind_of(f, sv, cv) == 'substitute' and not (f == 'SubType' and sv.lower() == 'twin') and sv and cv for f, sv, cv in DIFFS[g])
SUBSTITUTION_ART = {g for g in DIFFS if _is_word_substitution(g)}      # {17, 39, 44, 66, 173, 281, 509}
for g in SUBSTITUTION_ART:
    if CLASSES[g] == {'substitute'}:     # pure substitutions leave the error sets; #66 keeps its backtick error as well
        WITNESS[g] = ('art', "the substituted value describes what the piece visibly shows (collector's eye, D59); the other side keeps the formula label")
CHAIN_WRONG = sorted(g for g, (v, _) in WITNESS.items() if v == 'chain wrong')
SHEET_WRONG = sorted(g for g, (v, _) in WITNESS.items() if v == 'sheet wrong')
UNDECIDED = sorted(g for g, (v, _) in WITNESS.items() if v in ('undecidable', 'mixed'))

# ---------- D58/D63: Pindar's filename layer as tags ----------
import math as _m
SQUARE_MARK = {g for g in range(1, 129) if re.match(r'0*%d_s_' % g, FILENAME.get(g, ''))}
SQUARE_UNMARKED = {g for g in range(1, 129) if _m.isqrt(g) ** 2 == g and g not in SQUARE_MARK and HIDDENTYPE.get(g, '').lower() == 'c'}
SMOOTH_MARK = {g for g in range(1, 129) if 'smooth' in FILENAME.get(g, '')}
PRIMORDIAL_TRANSLATION = {g: ('elder' if 'elder' in FILENAME[g] else 'ancient') for g in range(1, 129) if ('ancient' in FILENAME.get(g, '') or 'elder' in FILENAME.get(g, ''))}
REVIEW_GROUP = {g: re.sub(r'^\d+_', '', FILENAME[g]).rsplit('.', 1)[0].lstrip('_') for g in range(445, 513) if FILENAME.get(g)}
