"""Reproducible recurrence study; no model labels or invented player difficulty.""" from __future__ import annotations from collections import Counter, defaultdict from dataclasses import asdict import datetime as dt from pathlib import Path from typing import Any, Iterable from app.services.connections_data import ConnectionsPuzzle from app.services.connections_workbench import normalise from app.services.puzzle_archive import content_hash METHOD = "connections-recurrence-v1" def recurrence_study(records: Iterable[ConnectionsPuzzle], *, through: dt.date) -> dict[str, Any]: selected = sorted( (record for record in records if record.date <= through.isoformat()), key=lambda record: record.date, ) if not selected: raise ValueError("No source-backed puzzles in the requested study period") appearances: dict[str, list[dict[str, Any]]] = defaultdict(list) display: dict[str, str] = {} for record in selected: for group in record.groups: for word in group.words: key = normalise(word) display.setdefault(key, word) appearances[key].append( {"date": record.date, "category": group.title, "colour": group.color} ) rows = [] for key, events in appearances.items(): rows.append( { "entry": display[key], "normalised": key, "appearances": len(events), "distinct_category_labels": len({event["category"] for event in events}), "distinct_colours": len({event["colour"] for event in events}), "first_date": events[0]["date"], "last_date": events[-1]["date"], } ) rows.sort(key=lambda row: (-row["appearances"], row["normalised"])) counts = Counter(str(min(row["appearances"], 5)) for row in rows) days = {record.date for record in selected} first = dt.date.fromisoformat(selected[0].date) last = dt.date.fromisoformat(selected[-1].date) missing = [ (first + dt.timedelta(days=index)).isoformat() for index in range((last - first).days + 1) if (first + dt.timedelta(days=index)).isoformat() not in days ] facts = [asdict(record) for record in selected] method_hash = content_hash( { "research": Path(__file__).read_text(), "normalisation": Path(__file__).with_name("connections_workbench.py").read_text(), } ) return { "version": METHOD, "snapshot_id": content_hash( {"method": METHOD, "method_hash": method_hash, "records": facts} ), "method_hash": method_hash, "question": "How often do recorded entries recur under different official category labels?", "source_hash": content_hash(facts), "data_from": selected[0].date, "data_through": selected[-1].date, "requested_through": through.isoformat(), "puzzles": len(selected), "groups": 4 * len(selected), "card_appearances": 16 * len(selected), "distinct_normalised_entries": len(rows), "repeated_entries": sum(row["appearances"] > 1 for row in rows), "entries_with_multiple_labels": sum(row["distinct_category_labels"] > 1 for row in rows), "missing_dates": missing, "appearance_bins": {str(i): counts[str(i)] for i in range(1, 6)}, "normalisation": "NFKD/casefold; strip combining marks; punctuation becomes spaces; collapse whitespace. Display originals are retained.", "taxonomy_version": "connections-learning-v1; no inferred semantic tags used", "limitations": [ "Different category labels do not prove different senses.", "Source corrections can change counts; retain this exact snapshot.", "Picture descriptions are counted as labels and can differ from visible image recognition.", "This is an archive census, not player difficulty or representative solve statistics.", "Publication of prose and source-derived downloads awaits review and source reuse checks.", ], "publication_status": "awaiting_human_review", "rows": rows, }