"""Hypothesis generation by morphological analysis (a Zwicky box). The artifact's ledger folder may hold box.yaml: dimensions: nature: [natural language, cipher of a known language, constructed language, ...] milieu: [monastic, court, university, ...] ... rules: # combinations the evidence rules out, each tied to a finding - when: {nature: one-to-one substitution} excluded_by: F-013 - when: {nature: meaningless, purpose: herbal} # all keys must match for the rule to fire excluded_by: F-027 soft: true # soft rules demote instead of excluding weights: # optional prior plausibility per option (default 1) milieu: {monastic: 1.5} `enumerate_box` lists every combination, removes the excluded ones, and ranks the survivors by prior weight and by how many soft rules they break. The survivors are candidates: an analyst (or a model prompted with the ledger) turns the most diagnostic ones into hypotheses with readings. """ import itertools, os import yaml def load_box(root): p = os.path.join(root, "box.yaml") return yaml.safe_load(open(p)) if os.path.exists(p) else None def enumerate_box(box): dims = box["dimensions"]; names = list(dims); rules = box.get("rules", []); weights = box.get("weights", {}) out = []; excluded = 0 for combo in itertools.product(*[dims[n] for n in names]): c = dict(zip(names, combo)); hard = []; soft = [] for r in rules: if all((c.get(k) in v) if isinstance(v, list) else c.get(k) == v for k, v in r["when"].items()): (soft if r.get("soft") else hard).append(r["excluded_by"]) if hard: excluded += 1; continue w = 1.0 for n in names: w *= weights.get(n, {}).get(c[n], 1.0) out.append(dict(combo=c, prior=round(w, 3), strained_by=soft, score=round(w / (1 + len(soft)), 3))) out.sort(key=lambda x: -x["score"]) return out, excluded def contrast_sets(survivors, by="encoding", then="genre"): """group survivors by the dimension the next tests discriminate (usually encoding), reporting for each option how many combinations survive, how strained the best one is, and the best `then` option""" import collections g = collections.defaultdict(list) for s in survivors: g[s["combo"][by]].append(s) out = [] for k, v in g.items(): best = max(v, key=lambda s: s["score"]); sub = collections.Counter(s["combo"][then] for s in v) out.append(dict(option=k, survivors=len(v), best_score=best["score"], strained_by=best["strained_by"], best_combo=best["combo"], spread=dict(sub))) return sorted(out, key=lambda x: -x["best_score"])